From 1c7ec74c618c5bbb3d3889c93ca6fed7e06ed285 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 13:48:28 -0400 Subject: [PATCH 01/20] refactor(lambdas): add @branch/lambda-http shared routing layer No lambda is converted yet -- this only lands the package the conversions build on, so it is a pure addition. The six handlers each route with a chain of `if (normalizedPath === ...)` statements that test two or three path spellings per route, because API Gateway's {proxy+} forwards the full path (/projects/7) while the shared dev-server strips the first segment (/7). Params come out of hand-rolled `split('/')[2]` and regex tests, correctness depends on `if` ordering that nothing enforces, and `json()` is defined six times over with `requireAuth` three times. @branch/lambda-http replaces that with a declarative route table: - dispatch({ prefix, routes }) canonicalizes the path to the prefixed shape so one table serves both callers, matches `:param` segments, and centralizes OPTIONS preflight, //health, 404 and 500. - json() with CORS headers, parseBody(), requireAuth() and a createAuthGuard() factory that binds a service's db-scoped authenticateRequest. - 28 unit tests, including route precedence and both path shapes. The dispatch/match/response/types modules are recovered from the closed PR #257; the auth and body helpers are new. No infrastructure change is needed -- {proxy+} and ANY already landed on main via PR #279. CI: both workflows build lambda-http after lambda-auth (it consumes that package's dist), a shared-http job runs its tests and is added to the lambda-tests gate, and lambda-deploy triggers on shared/lambda-http/**. Note: lambda-deploy still does not trigger on shared/lambda-auth/**, a pre-existing gap left alone here. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/lambda-deploy.yml | 6 +- .github/workflows/lambda-tests.yml | 26 +- shared/lambda-http/README.md | 57 + shared/lambda-http/jest.config.js | 5 + shared/lambda-http/package-lock.json | 4645 ++++++++++++++++++++++ shared/lambda-http/package.json | 23 + shared/lambda-http/src/authz.ts | 43 + shared/lambda-http/src/body.ts | 8 + shared/lambda-http/src/dispatch.ts | 55 + shared/lambda-http/src/index.ts | 7 + shared/lambda-http/src/match.ts | 26 + shared/lambda-http/src/response.ts | 15 + shared/lambda-http/src/types.ts | 29 + shared/lambda-http/test/authz.test.ts | 69 + shared/lambda-http/test/dispatch.test.ts | 101 + shared/lambda-http/test/match.test.ts | 34 + shared/lambda-http/tsconfig.json | 17 + 17 files changed, 5164 insertions(+), 2 deletions(-) create mode 100644 shared/lambda-http/README.md create mode 100644 shared/lambda-http/jest.config.js create mode 100644 shared/lambda-http/package-lock.json create mode 100644 shared/lambda-http/package.json create mode 100644 shared/lambda-http/src/authz.ts create mode 100644 shared/lambda-http/src/body.ts create mode 100644 shared/lambda-http/src/dispatch.ts create mode 100644 shared/lambda-http/src/index.ts create mode 100644 shared/lambda-http/src/match.ts create mode 100644 shared/lambda-http/src/response.ts create mode 100644 shared/lambda-http/src/types.ts create mode 100644 shared/lambda-http/test/authz.test.ts create mode 100644 shared/lambda-http/test/dispatch.test.ts create mode 100644 shared/lambda-http/test/match.test.ts create mode 100644 shared/lambda-http/tsconfig.json diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index 58636365..64848e8b 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -6,6 +6,7 @@ on: paths: - 'apps/backend/lambdas/**' - 'shared/types/**' + - 'shared/lambda-http/**' - 'apps/backend/db/migrations/**' workflow_dispatch: inputs: @@ -41,7 +42,7 @@ jobs: migrate=false code=false grep -q '^apps/backend/db/migrations/' <<<"$changed_files" && migrate=true - grep -qE '^(apps/backend/lambdas/|shared/types/)' <<<"$changed_files" && code=true + grep -qE '^(apps/backend/lambdas/|shared/types/|shared/lambda-http/)' <<<"$changed_files" && code=true # workflow_dispatch has no meaningful diff: apply everything, or # migrations only when explicitly asked (recovery / manual re-run). @@ -127,6 +128,9 @@ jobs: - name: Build shared lambda-auth package run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth + # lambda-http depends on lambda-auth's dist, so it builds second. + - name: Build shared lambda-http package + run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index fac7ff00..5a111f20 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -58,6 +58,9 @@ jobs: DATABASE_URL: postgres://branch_dev:password@localhost:5432/branch_db - name: Build shared lambda-auth package run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth + # lambda-http depends on lambda-auth's dist, so it builds second. + - name: Build shared lambda-http package + run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps @@ -253,12 +256,29 @@ jobs: - name: Run tests run: npm test --prefix shared/lambda-auth + # Same blind spot as shared-auth: discover only globs the lambdas, so the + # shared router would go untested. Every route in every lambda goes through it. + shared-http: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Build shared lambda-auth package + run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth + - name: Install dependencies + run: npm ci --prefix shared/lambda-http + - name: Run tests + run: npm test --prefix shared/lambda-http + # NOTE: do not rename this job. infrastructure/github/main.tf lists # "lambda-tests" as a required status check on main; renaming it here would # silently disable the gate rather than fail loudly. lambda-tests: name: lambda-tests - needs: [test, shared-auth, migrations-fresh, migrations-guard] + needs: [test, shared-auth, shared-http, migrations-fresh, migrations-guard] if: always() runs-on: ubuntu-latest steps: @@ -272,6 +292,10 @@ jobs: echo "shared/lambda-auth tests failed or were cancelled" exit 1 fi + if [ "${{ needs.shared-http.result }}" != "success" ]; then + echo "shared/lambda-http tests failed or were cancelled" + exit 1 + fi # Folded into this gate rather than added to branch protection, so # infrastructure/github/main.tf needs no change and there is no risk of # wedging the merge queue on a mistyped required-check context. diff --git a/shared/lambda-http/README.md b/shared/lambda-http/README.md new file mode 100644 index 00000000..be8a6cd9 --- /dev/null +++ b/shared/lambda-http/README.md @@ -0,0 +1,57 @@ +# @branch/lambda-http + +Shared HTTP layer for the lambdas in `apps/backend/lambdas`. Replaces the +per-handler `if (normalizedPath === ...)` chains with a declarative route table. + +## Usage + +```ts +// handler.ts +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; + +export const handler = (event: any) => dispatch(event, { prefix: 'projects', routes }); +``` + +```ts +// routes.ts — first match wins, so literals go before `:param` patterns +import type { Route } from '@branch/lambda-http'; + +export const routes: Route[] = [ + { method: 'GET', pattern: '/projects/dashboard', handler: getDashboard }, + { method: 'GET', pattern: '/projects/:id', handler: getProject }, +]; +``` + +A handler receives `{ event, params, method, path }` and returns an +`APIGatewayProxyResult`, normally via `json(status, body)`. + +## What dispatch handles centrally + +- **Both path shapes.** API Gateway's `{proxy+}` forwards the full path + (`/projects/7`); the shared dev-server strips the first segment (`/7`). + Paths are canonicalized to the prefixed form, so one table serves both. +- OPTIONS preflight, `GET //health`, 404, and 500. +- CORS headers on every response, via `json`. + +## Exports + +| Export | Purpose | +| --- | --- | +| `dispatch(event, { prefix, routes })` | Route an event; returns a response. | +| `json(status, body)` | JSON response with CORS headers. | +| `parseBody(event)` | Parse a JSON body; `null` when malformed. | +| `requireAuth(ctx, level, resourceUserId?)` | 401/403 response, or `undefined` when allowed. | +| `createAuthGuard(authenticate)` | Bind a service's db-scoped `authenticateRequest` into an authenticate-and-authorize guard. | +| `matchPattern(pattern, path)` | Params on match, `null` otherwise. | + +## Build + +Compiles to a gitignored `dist/` that lambdas consume as a `file:` dependency, +and depends on `@branch/lambda-auth`'s own `dist/`, so build that one first: + +```bash +npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth +npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http +npm test --prefix shared/lambda-http +``` diff --git a/shared/lambda-http/jest.config.js b/shared/lambda-http/jest.config.js new file mode 100644 index 00000000..37b24d51 --- /dev/null +++ b/shared/lambda-http/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], +}; diff --git a/shared/lambda-http/package-lock.json b/shared/lambda-http/package-lock.json new file mode 100644 index 00000000..e532c663 --- /dev/null +++ b/shared/lambda-http/package-lock.json @@ -0,0 +1,4645 @@ +{ + "name": "@branch/lambda-http", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, + "../lambda-auth": { + "name": "@branch/lambda-auth", + "version": "1.0.0", + "dependencies": { + "@branch/types": "file:../types", + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@branch/lambda-auth": { + "resolved": "../lambda-auth", + "link": true + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.162", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.162.tgz", + "integrity": "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ts-jest": { + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/shared/lambda-http/package.json b/shared/lambda-http/package.json new file mode 100644 index 00000000..f2777ffe --- /dev/null +++ b/shared/lambda-http/package.json @@ -0,0 +1,23 @@ +{ + "name": "@branch/lambda-http", + "version": "1.0.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "jest" + }, + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } +} diff --git a/shared/lambda-http/src/authz.ts b/shared/lambda-http/src/authz.ts new file mode 100644 index 00000000..5e486e41 --- /dev/null +++ b/shared/lambda-http/src/authz.ts @@ -0,0 +1,43 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; +import { checkAuthorization } from '@branch/lambda-auth'; +import type { AccessLevel, AuthContext } from '@branch/lambda-auth'; +import { json } from './response'; + +/** + * Turn an authorization decision into a response, or `undefined` when allowed. + * 401 when the caller never authenticated, 403 when they did but lack access. + */ +export function requireAuth( + authContext: AuthContext, + level: AccessLevel, + resourceUserId?: number | string, +): APIGatewayProxyResult | undefined { + const check = checkAuthorization(authContext, level, resourceUserId); + if (check.allowed) return undefined; + return authContext.isAuthenticated + ? json(403, { message: check.reason || 'Forbidden' }) + : json(401, { message: 'Authentication required' }); +} + +export type AuthGuardResult = + | { ctx: AuthContext; response?: undefined } + | { ctx?: undefined; response: APIGatewayProxyResult }; + +/** + * Bind a service's db-scoped `authenticateRequest` into a guard that + * authenticates and authorizes in one call. + */ +export function createAuthGuard( + authenticate: (event: any) => Promise, +) { + return async function guard( + event: any, + level: AccessLevel = 'AUTHENTICATED', + resourceUserId?: number | string, + ): Promise { + const ctx = await authenticate(event); + const denied = requireAuth(ctx, level, resourceUserId); + if (denied) return { response: denied }; + return { ctx }; + }; +} diff --git a/shared/lambda-http/src/body.ts b/shared/lambda-http/src/body.ts new file mode 100644 index 00000000..564e23c2 --- /dev/null +++ b/shared/lambda-http/src/body.ts @@ -0,0 +1,8 @@ +/** Parse a JSON request body. Returns `{}` for an empty body, `null` when malformed. */ +export function parseBody(event: any): Record | null { + try { + return event.body ? (JSON.parse(event.body) as Record) : {}; + } catch { + return null; + } +} diff --git a/shared/lambda-http/src/dispatch.ts b/shared/lambda-http/src/dispatch.ts new file mode 100644 index 00000000..8170b631 --- /dev/null +++ b/shared/lambda-http/src/dispatch.ts @@ -0,0 +1,55 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; +import { json } from './response'; +import { matchPattern } from './match'; +import type { DispatchOptions } from './types'; + +/** + * Route a Lambda event to the first matching route and return its response. + * + * Canonicalizes the incoming path to the full prefixed shape so a single route + * table works under both: + * - API Gateway `{proxy+}` — delivers the full path, e.g. `/auth/login`. + * - the shared dev-server — routes by first segment then strips it, e.g. `/login` + * (and `/` for a bare service root). + * + * Handles OPTIONS preflight, `//health`, 404, and 500 centrally. + */ +export async function dispatch( + event: any, + { prefix, routes }: DispatchOptions, +): Promise { + try { + const rawPath: string = event.rawPath || event.path || '/'; + let path = rawPath.replace(/\/+$/, '') || '/'; + const method = ( + event.requestContext?.http?.method || + event.httpMethod || + 'GET' + ).toUpperCase(); + + // Canonicalize to `/...` when the prefix was stripped (dev-server). + const base = `/${prefix}`; + if (path !== base && !path.startsWith(`${base}/`)) { + path = path === '/' ? base : base + path; + } + + if (method === 'OPTIONS') return json(200, {}); + + if (path === `${base}/health` && method === 'GET') { + return json(200, { ok: true, timestamp: new Date().toISOString() }); + } + + for (const route of routes) { + if (route.method.toUpperCase() !== method) continue; + const params = matchPattern(route.pattern, path); + if (params) { + return await route.handler({ event, params, method, path }); + } + } + + return json(404, { message: 'Not Found', path, method }); + } catch (err) { + console.error('Lambda error:', err); + return json(500, { message: 'Internal Server Error' }); + } +} diff --git a/shared/lambda-http/src/index.ts b/shared/lambda-http/src/index.ts new file mode 100644 index 00000000..7b95e593 --- /dev/null +++ b/shared/lambda-http/src/index.ts @@ -0,0 +1,7 @@ +export * from './types'; +export { json } from './response'; +export { matchPattern } from './match'; +export { dispatch } from './dispatch'; +export { parseBody } from './body'; +export { requireAuth, createAuthGuard } from './authz'; +export type { AuthGuardResult } from './authz'; diff --git a/shared/lambda-http/src/match.ts b/shared/lambda-http/src/match.ts new file mode 100644 index 00000000..73d0c806 --- /dev/null +++ b/shared/lambda-http/src/match.ts @@ -0,0 +1,26 @@ +/** + * Match a route pattern against a path. Patterns use `:name` for params, e.g. + * `/projects/:id/members`. Returns captured params on match, or `null` if no match. + * Segment counts must be equal (no greedy/optional segments). + */ +export function matchPattern( + pattern: string, + path: string, +): Record | null { + const pSeg = pattern.split('/').filter(Boolean); + const aSeg = path.split('/').filter(Boolean); + if (pSeg.length !== aSeg.length) return null; + + const params: Record = {}; + for (let i = 0; i < pSeg.length; i++) { + const p = pSeg[i]; + const a = aSeg[i]; + if (p.startsWith(':')) { + if (!a) return null; + params[p.slice(1)] = decodeURIComponent(a); + } else if (p !== a) { + return null; + } + } + return params; +} diff --git a/shared/lambda-http/src/response.ts b/shared/lambda-http/src/response.ts new file mode 100644 index 00000000..b4ff8626 --- /dev/null +++ b/shared/lambda-http/src/response.ts @@ -0,0 +1,15 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; + +/** JSON response with permissive CORS headers (browser calls cross-origin to API Gateway). */ +export function json(statusCode: number, body: unknown): APIGatewayProxyResult { + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', + }, + body: JSON.stringify(body), + }; +} diff --git a/shared/lambda-http/src/types.ts b/shared/lambda-http/src/types.ts new file mode 100644 index 00000000..0754a4d9 --- /dev/null +++ b/shared/lambda-http/src/types.ts @@ -0,0 +1,29 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; + +/** Context handed to a matched route handler. */ +export interface RouteCtx { + /** Raw Lambda event (API Gateway proxy or Function URL / dev-server shape). */ + event: any; + /** Path params captured from the route pattern (e.g. `:id` -> params.id). */ + params: Record; + /** Uppercased HTTP method. */ + method: string; + /** Canonical, full-prefixed request path (e.g. `/projects/7/members`). */ + path: string; +} + +export type RouteHandler = (ctx: RouteCtx) => Promise; + +export interface Route { + /** HTTP method, case-insensitive. */ + method: string; + /** Full prefixed path pattern with `:param` segments, e.g. `/projects/:id/members`. */ + pattern: string; + handler: RouteHandler; +} + +export interface DispatchOptions { + /** Service prefix without slashes, e.g. `auth`, `projects`. */ + prefix: string; + routes: Route[]; +} diff --git a/shared/lambda-http/test/authz.test.ts b/shared/lambda-http/test/authz.test.ts new file mode 100644 index 00000000..16242b5f --- /dev/null +++ b/shared/lambda-http/test/authz.test.ts @@ -0,0 +1,69 @@ +import type { AuthContext } from '@branch/lambda-auth'; +import { createAuthGuard, requireAuth } from '../src/authz'; +import { parseBody } from '../src/body'; + +const anon: AuthContext = { isAuthenticated: false }; +const user = (userId: number, isAdmin = false): AuthContext => ({ + isAuthenticated: true, + user: { userId, isAdmin } as AuthContext['user'], +}); + +const body = (res: { body: string }) => JSON.parse(res.body); + +describe('requireAuth', () => { + it('allows a permitted request', () => { + expect(requireAuth(user(1), 'AUTHENTICATED')).toBeUndefined(); + }); + + it('401s an unauthenticated caller', () => { + const res = requireAuth(anon, 'AUTHENTICATED')!; + expect(res.statusCode).toBe(401); + expect(body(res).message).toBe('Authentication required'); + }); + + it('403s an authenticated caller who lacks access', () => { + const res = requireAuth(user(1), 'ADMIN')!; + expect(res.statusCode).toBe(403); + expect(body(res).message).toBe('Admin access required'); + }); + + it('passes the resource owner through for SELF', () => { + expect(requireAuth(user(5), 'SELF', 5)).toBeUndefined(); + expect(requireAuth(user(5), 'SELF', 6)!.statusCode).toBe(403); + }); +}); + +describe('createAuthGuard', () => { + it('returns the context when allowed', async () => { + const guard = createAuthGuard(async () => user(3, true)); + const result = await guard({}, 'ADMIN'); + expect(result.response).toBeUndefined(); + expect(result.ctx?.user?.userId).toBe(3); + }); + + it('returns a response when denied', async () => { + const guard = createAuthGuard(async () => anon); + const result = await guard({}, 'ADMIN'); + expect(result.ctx).toBeUndefined(); + expect(result.response?.statusCode).toBe(401); + }); + + it('defaults to AUTHENTICATED', async () => { + const guard = createAuthGuard(async () => user(1)); + expect((await guard({})).response).toBeUndefined(); + }); +}); + +describe('parseBody', () => { + it('parses JSON', () => { + expect(parseBody({ body: '{"a":1}' })).toEqual({ a: 1 }); + }); + + it('returns an empty object for an absent body', () => { + expect(parseBody({})).toEqual({}); + }); + + it('returns null for malformed JSON', () => { + expect(parseBody({ body: '{' })).toBeNull(); + }); +}); diff --git a/shared/lambda-http/test/dispatch.test.ts b/shared/lambda-http/test/dispatch.test.ts new file mode 100644 index 00000000..2af4fa61 --- /dev/null +++ b/shared/lambda-http/test/dispatch.test.ts @@ -0,0 +1,101 @@ +import { dispatch } from '../src/dispatch'; +import { json } from '../src/response'; +import type { Route } from '../src/types'; + +const ok = (label: string): Route['handler'] => async (ctx) => + json(200, { label, params: ctx.params, path: ctx.path }); + +const routes: Route[] = [ + { method: 'GET', pattern: '/projects', handler: ok('list') }, + { method: 'GET', pattern: '/projects/dashboard', handler: ok('dashboard') }, + { method: 'GET', pattern: '/projects/:id', handler: ok('get-one') }, + { method: 'PUT', pattern: '/projects/:id', handler: ok('update') }, +]; + +const event = (method: string, rawPath: string) => ({ + rawPath, + requestContext: { http: { method } }, +}); + +const body = (res: { body: string }) => JSON.parse(res.body); + +describe('dispatch', () => { + it('routes the full prefixed path that API Gateway forwards', async () => { + const res = await dispatch(event('GET', '/projects'), { prefix: 'projects', routes }); + expect(res.statusCode).toBe(200); + expect(body(res).label).toBe('list'); + }); + + it('routes the prefix-stripped path the dev-server forwards', async () => { + const res = await dispatch(event('GET', '/'), { prefix: 'projects', routes }); + expect(body(res).label).toBe('list'); + }); + + it('canonicalizes a stripped sub-path back under the prefix', async () => { + const res = await dispatch(event('GET', '/7'), { prefix: 'projects', routes }); + expect(body(res)).toMatchObject({ label: 'get-one', params: { id: '7' }, path: '/projects/7' }); + }); + + it('honours route order, so a literal wins over a param pattern', async () => { + const res = await dispatch(event('GET', '/projects/dashboard'), { + prefix: 'projects', + routes, + }); + expect(body(res).label).toBe('dashboard'); + }); + + it('discriminates on method', async () => { + const res = await dispatch(event('PUT', '/projects/7'), { prefix: 'projects', routes }); + expect(body(res).label).toBe('update'); + }); + + it('ignores a trailing slash', async () => { + const res = await dispatch(event('GET', '/projects/'), { prefix: 'projects', routes }); + expect(body(res).label).toBe('list'); + }); + + it('accepts the API Gateway event shape (path + httpMethod)', async () => { + const res = await dispatch( + { path: '/projects/7', httpMethod: 'get' }, + { prefix: 'projects', routes }, + ); + expect(body(res).label).toBe('get-one'); + }); + + it('answers OPTIONS preflight with 200 and CORS headers', async () => { + const res = await dispatch(event('OPTIONS', '/projects/7'), { prefix: 'projects', routes }); + expect(res.statusCode).toBe(200); + expect(res.headers?.['Access-Control-Allow-Origin']).toBe('*'); + }); + + it('serves health under both path shapes without hitting a route', async () => { + for (const path of ['/projects/health', '/health']) { + const res = await dispatch(event('GET', path), { prefix: 'projects', routes }); + expect(res.statusCode).toBe(200); + expect(body(res).ok).toBe(true); + } + }); + + it('404s an unmatched path', async () => { + const res = await dispatch(event('GET', '/projects/7/nope'), { prefix: 'projects', routes }); + expect(res.statusCode).toBe(404); + expect(body(res)).toMatchObject({ message: 'Not Found', path: '/projects/7/nope' }); + }); + + it('500s when a handler throws, without leaking the error', async () => { + const boom: Route[] = [ + { + method: 'GET', + pattern: '/projects', + handler: async () => { + throw new Error('secret detail'); + }, + }, + ]; + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const res = await dispatch(event('GET', '/projects'), { prefix: 'projects', routes: boom }); + expect(res.statusCode).toBe(500); + expect(res.body).not.toContain('secret detail'); + spy.mockRestore(); + }); +}); diff --git a/shared/lambda-http/test/match.test.ts b/shared/lambda-http/test/match.test.ts new file mode 100644 index 00000000..6d81b432 --- /dev/null +++ b/shared/lambda-http/test/match.test.ts @@ -0,0 +1,34 @@ +import { matchPattern } from '../src/match'; + +describe('matchPattern', () => { + it('matches a literal path', () => { + expect(matchPattern('/projects', '/projects')).toEqual({}); + }); + + it('captures named params', () => { + expect(matchPattern('/projects/:id/members', '/projects/7/members')).toEqual({ + id: '7', + }); + }); + + it('captures several params', () => { + expect(matchPattern('/a/:x/b/:y', '/a/1/b/2')).toEqual({ x: '1', y: '2' }); + }); + + it('decodes percent-encoded params', () => { + expect(matchPattern('/donors/:name', '/donors/a%20b')).toEqual({ name: 'a b' }); + }); + + it('rejects a segment-count mismatch', () => { + expect(matchPattern('/projects/:id', '/projects')).toBeNull(); + expect(matchPattern('/projects/:id', '/projects/7/members')).toBeNull(); + }); + + it('rejects a differing literal segment', () => { + expect(matchPattern('/projects/:id/members', '/projects/7/donors')).toBeNull(); + }); + + it('treats trailing slashes as equivalent', () => { + expect(matchPattern('/projects/', '/projects')).toEqual({}); + }); +}); diff --git a/shared/lambda-http/tsconfig.json b/shared/lambda-http/tsconfig.json new file mode 100644 index 00000000..e57dbd9f --- /dev/null +++ b/shared/lambda-http/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 406799a2902dd936be66c7f279252de05d8ff3fe Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 13:57:44 -0400 Subject: [PATCH 02/20] refactor(users): move to declarative route table via @branch/lambda-http MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure reorganization, no behaviour change: - handler.ts is now a one-liner: dispatch(event, { prefix: 'users', routes }). - routes.ts holds the ordered Route[] table, bracketed by the ROUTES-START/ ROUTES-END markers (moved here from handler.ts) in the same order as the original if-chain: GET /users, GET /users/:userId, PATCH /users/:userId, DELETE /users/:userId, POST /users. - controllers/users.ts holds one RouteHandler per route, calling Kysely directly (no services/ layer — this lambda is thin). Auth now goes through createAuthGuard(authenticateRequest) from @branch/lambda-http instead of a handler-local requireAuth/checkAuthorization pairing; @branch/lambda-auth's checkAuthorization (which the shared requireAuth calls) is behaviourally identical to the removed local copy for every level this lambda uses. - Local json()/requireAuth() helpers deleted in favor of the @branch/lambda-http exports. dev-server.ts, db.ts, auth.ts, validation-utils.ts, swagger-utils.ts untouched. - Added @branch/lambda-http as a dependency, regenerated package-lock.json. - tsconfig.json now includes controllers/**/*.ts. - Added a route-precedence unit test (literal /users/me vs /users/:userId). Existing suites pass unmodified. Co-Authored-By: Claude Sonnet 5 --- .../lambdas/users/controllers/users.ts | 278 ++++++++++++++ apps/backend/lambdas/users/handler.ts | 346 +----------------- apps/backend/lambdas/users/package-lock.json | 21 ++ apps/backend/lambdas/users/package.json | 1 + apps/backend/lambdas/users/routes.ts | 12 + .../lambdas/users/test/user.unit.test.ts | 28 ++ apps/backend/lambdas/users/tsconfig.json | 2 +- 7 files changed, 344 insertions(+), 344 deletions(-) create mode 100644 apps/backend/lambdas/users/controllers/users.ts create mode 100644 apps/backend/lambdas/users/routes.ts diff --git a/apps/backend/lambdas/users/controllers/users.ts b/apps/backend/lambdas/users/controllers/users.ts new file mode 100644 index 00000000..4da09c8e --- /dev/null +++ b/apps/backend/lambdas/users/controllers/users.ts @@ -0,0 +1,278 @@ +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminDeleteUserCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json, createAuthGuard, type RouteHandler } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; +import { UserValidationUtils } from '../validation-utils'; + +const cognitoClient = new CognitoIdentityProviderClient({ + region: process.env.AWS_REGION || 'us-east-2', +}); + +const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; + +const guard = createAuthGuard(authenticateRequest); + +export const listUsers: RouteHandler = async ({ event }) => { + const auth = await guard(event, 'ADMIN'); + if (auth.response) return auth.response; + + const queryParams = event.queryStringParameters || {}; + const page = queryParams.page ? parseInt(queryParams.page, 10) : null; + const limit = queryParams.limit ? parseInt(queryParams.limit, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.users') + .select(db.fn.count('user_id').as('count')) + .executeTakeFirst(); + + const totalUsers = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalUsers / limit); + + const users = await db + .selectFrom('branch.users') + .selectAll() + .orderBy('user_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + return json(200, { + users, + pagination: { + page, + limit, + totalUsers, + totalPages + } + }); + } + + const users = await db + .selectFrom('branch.users') + .selectAll() + .execute(); + + return json(200, { users }); +}; + +export const getUser: RouteHandler = async ({ event, params }) => { + const userId = params.userId; + const auth = await guard(event, 'ADMIN_OR_SELF', userId); + if (auth.response) return auth.response; + + if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); + + const user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + return json(200, { + ok: true, + route: 'GET /users/{userId}', + pathParams: { userId }, + body: { + userId: user.user_id, + email: user.email, + name: user.name, + isAdmin: user.is_admin, + profile_image: user.profile_image, + } + }); +}; + +export const patchUser: RouteHandler = async ({ event, params }) => { + const userId = params.userId; + const auth = await guard(event, 'ADMIN_OR_SELF', userId); + if (auth.response) return auth.response; + const authContext = auth.ctx; + + if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); + const body = event.body ? JSON.parse(event.body) as Record : {}; + + // make sure user exists + let user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + const updates: { name?: string; is_admin?: boolean; profile_image?: string } = {}; + + // email is the Cognito username and nothing here syncs it, so it is immutable + if (body.email !== undefined && body.email !== null && body.email !== '') { + return json(400, { message: 'email cannot be changed' }); + } + + const nameResult = UserValidationUtils.validateName(body.name); + if (!nameResult.isValid) return json(400, { message: nameResult.error }); + if (nameResult.value != null) updates.name = nameResult.value; + + const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); + if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); + if (isAdminResult.value != null) { + // is_admin is a privilege grant, not profile data. The ADMIN_OR_SELF + // check above intentionally lets a non-admin PATCH their own row, so + // without this gate any user could PATCH { isAdmin: true } to their own + // userId and self-promote. validateIsAdmin returns value: null when the + // field is absent, so ordinary self-service edits are unaffected. + if (!authContext.user?.isAdmin) { + return json(403, { message: 'Only an admin can change isAdmin' }); + } + updates.is_admin = isAdminResult.value; + } + + const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); + if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); + if (profileImageResult.value != null) updates.profile_image = profileImageResult.value; + + if (Object.keys(updates).length === 0) { + return json(400, { message: 'No valid fields provided to update' }); + } + + // update + await db.updateTable('branch.users') + .set(updates) + .where('user_id', '=', Number(userId)) + .execute(); + + // get updated user + let updatedUser = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); + + return json(200, { ok: true, route: 'PATCH /users/{userId}', pathParams: { userId }, body: { email: updatedUser!.email, name: updatedUser!.name, isAdmin: updatedUser!.is_admin, profileImage: updatedUser!.profile_image } }); +}; + +export const deleteUser: RouteHandler = async ({ event, params }) => { + const auth = await guard(event, 'ADMIN'); + if (auth.response) return auth.response; + + const userId = params.userId; + if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); + + const user = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).select('email').executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); + + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'User not found' }); + } + + // the Cognito user must go too, or the email can never be re-invited + let cognitoDeleted = true; + if (!USER_POOL_ID) { + console.error('COGNITO_USER_POOL_ID is not set; skipping Cognito delete for', user.email); + cognitoDeleted = false; + } else { + try { + await cognitoClient.send(new AdminDeleteUserCommand({ UserPoolId: USER_POOL_ID, Username: user.email })); + } catch (err: any) { + if (err?.name !== 'UserNotFoundException') { + console.error('Cognito delete error:', err); + cognitoDeleted = false; + } + } + } + + return json(200, { ok: true, route: 'DELETE /users/{userId}', pathParams: { userId }, cognitoDeleted }); +}; + +export const createUser: RouteHandler = async ({ event }) => { + const auth = await guard(event, 'ADMIN'); + if (auth.response) return auth.response; + + const body = event.body + ? (JSON.parse(event.body) as Record) + : {}; + + // email, name, and isAdmin are required on create + if (!body.email || !body.name || body.isAdmin === undefined || body.isAdmin === null) { + return json(400, { message: 'email, name, and isAdmin are required' }); + } + + // validate the type/format of each field + const emailResult = UserValidationUtils.validateEmail(body.email); + if (!emailResult.isValid) return json(400, { message: emailResult.error }); + + const nameResult = UserValidationUtils.validateName(body.name); + if (!nameResult.isValid) return json(400, { message: nameResult.error }); + + const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); + if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); + + const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); + if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); + + const email = emailResult.value as string; + const name = nameResult.value as string; + const isAdmin = isAdminResult.value as boolean; + const profile_image = profileImageResult.value ?? undefined; + + // Check if user with this email already exists in DB + const existingUser = await db + .selectFrom('branch.users') + .where('email', '=', email) + .selectAll() + .executeTakeFirst(); + + if (existingUser) { + return json(409, { message: 'User with this email already exists' }); + } + + // Create user in Cognito via AdminCreateUser — sends invite email with temp password + let cognitoSub: string; + try { + const cognitoResponse = await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email, + DesiredDeliveryMediums: ['EMAIL'], + UserAttributes: [ + { Name: 'email', Value: email }, + { Name: 'email_verified', Value: 'true' }, + { Name: 'name', Value: name }, + ], + })); + const sub = cognitoResponse.User?.Attributes?.find(a => a.Name === 'sub')?.Value; + if (!sub) throw new Error('No sub returned from AdminCreateUser'); + cognitoSub = sub; + } catch (err: any) { + console.error('Cognito AdminCreateUser error:', err); + if (err.name === 'UsernameExistsException') { + return json(409, { message: 'User with this email already exists' }); + } + return json(500, { message: 'Failed to create user in authentication service' }); + } + + // Insert into database with cognito_sub + try { + await db + .insertInto('branch.users') + .values({ cognito_sub: cognitoSub, email, name, is_admin: isAdmin, profile_image }) + .execute(); + } catch (err: any) { + console.error('Database insert error:', err); + // Rollback: delete Cognito user to keep systems in sync + try { + await cognitoClient.send(new AdminDeleteUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email, + })); + console.log('Rolled back Cognito user after database failure'); + } catch (rollbackErr) { + console.error('Failed to rollback Cognito user:', rollbackErr); + } + return json(500, { message: 'Failed to create user' }); + } + + return json(201, { + ok: true, + route: 'POST /users', + pathParams: {}, + body: { + email, + name, + isAdmin, + }, + }); +}; diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index 4f32be7a..b9f288d2 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,344 +1,4 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { - CognitoIdentityProviderClient, - AdminCreateUserCommand, - AdminDeleteUserCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import db from './db' -import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; -import { UserValidationUtils } from './validation-utils'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -const cognitoClient = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION || 'us-east-2', -}); - -const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; - -function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { - const authCheck = checkAuthorization(authContext, level, resourceUserId); - if (!authCheck.allowed) { - return authContext.isAuthenticated - ? json(403, { message: authCheck.reason || 'Forbidden' }) - : json(401, { message: 'Authentication required' }); - } -} - - -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /users[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/users(?=\/|$)/, '') || '/'; - let normalizedPath = rawPath.replace(/\/$/, ''); - if (normalizedPath.length === 0) { - normalizedPath = '/'; - } - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight — must return 2xx before auth, or the browser blocks it. - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - const authContext: AuthContext = await authenticateRequest(event); - - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - - // GET /users - if ((normalizedPath === '/users' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - // TODO: Add your business logic here - const queryParams = event.queryStringParameters || {}; - const page = queryParams.page ? parseInt(queryParams.page, 10) : null; - const limit = queryParams.limit ? parseInt(queryParams.limit, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.users') - .select(db.fn.count('user_id').as('count')) - .executeTakeFirst(); - - const totalUsers = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalUsers / limit); - - const users = await db - .selectFrom('branch.users') - .selectAll() - .orderBy('user_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - return json(200, { - users, - pagination: { - page, - limit, - totalUsers, - totalPages - } - }); - } - - const users = await db - .selectFrom('branch.users') - .selectAll() - .execute(); - - return json(200, { users }); - } - - // GET /{userId} - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'GET') { - const userId = normalizedPath.split('/')[1]; - const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); - if (authError) return authError; - - if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); - - const user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - return json(200, { - ok: true, - route: 'GET /users/{userId}', - pathParams: { userId }, - body: { - userId: user.user_id, - email: user.email, - name: user.name, - isAdmin: user.is_admin, - profile_image: user.profile_image, - } - }); - } - - // PATCH /{userId} (dev server strips /users prefix) - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'PATCH') { - const userId = normalizedPath.split('/')[1]; - const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); - if (authError) return authError; - - if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // make sure user exists - let user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - const updates: { name?: string; is_admin?: boolean; profile_image?: string } = {}; - - // email is the Cognito username and nothing here syncs it, so it is immutable - if (body.email !== undefined && body.email !== null && body.email !== '') { - return json(400, { message: 'email cannot be changed' }); - } - - const nameResult = UserValidationUtils.validateName(body.name); - if (!nameResult.isValid) return json(400, { message: nameResult.error }); - if (nameResult.value != null) updates.name = nameResult.value; - - const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); - if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); - if (isAdminResult.value != null) { - // is_admin is a privilege grant, not profile data. The ADMIN_OR_SELF - // check above intentionally lets a non-admin PATCH their own row, so - // without this gate any user could PATCH { isAdmin: true } to their own - // userId and self-promote. validateIsAdmin returns value: null when the - // field is absent, so ordinary self-service edits are unaffected. - if (!authContext.user?.isAdmin) { - return json(403, { message: 'Only an admin can change isAdmin' }); - } - updates.is_admin = isAdminResult.value; - } - - const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); - if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); - if (profileImageResult.value != null) updates.profile_image = profileImageResult.value; - - if (Object.keys(updates).length === 0) { - return json(400, { message: 'No valid fields provided to update' }); - } - - // update - await db.updateTable('branch.users') - .set(updates) - .where('user_id', '=', Number(userId)) - .execute(); - - // get updated user - let updatedUser = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - - return json(200, { ok: true, route: 'PATCH /users/{userId}', pathParams: { userId }, body: { email: updatedUser!.email, name: updatedUser!.name, isAdmin: updatedUser!.is_admin, profileImage: updatedUser!.profile_image } }); - } - - // DELETE /users/{userId} - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const userId = normalizedPath.split('/')[1]; - if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); - - const user = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).select('email').executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); - - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'User not found' }); - } - - // the Cognito user must go too, or the email can never be re-invited - let cognitoDeleted = true; - if (!USER_POOL_ID) { - console.error('COGNITO_USER_POOL_ID is not set; skipping Cognito delete for', user.email); - cognitoDeleted = false; - } else { - try { - await cognitoClient.send(new AdminDeleteUserCommand({ UserPoolId: USER_POOL_ID, Username: user.email })); - } catch (err: any) { - if (err?.name !== 'UserNotFoundException') { - console.error('Cognito delete error:', err); - cognitoDeleted = false; - } - } - } - - return json(200, { ok: true, route: 'DELETE /users/{userId}', pathParams: { userId }, cognitoDeleted }); - } - - // POST /users - if ((normalizedPath === '/' || normalizedPath === '/users') && method === 'POST') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const body = event.body - ? (JSON.parse(event.body) as Record) - : {}; - - // email, name, and isAdmin are required on create - if (!body.email || !body.name || body.isAdmin === undefined || body.isAdmin === null) { - return json(400, { message: 'email, name, and isAdmin are required' }); - } - - // validate the type/format of each field - const emailResult = UserValidationUtils.validateEmail(body.email); - if (!emailResult.isValid) return json(400, { message: emailResult.error }); - - const nameResult = UserValidationUtils.validateName(body.name); - if (!nameResult.isValid) return json(400, { message: nameResult.error }); - - const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); - if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); - - const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); - if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); - - const email = emailResult.value as string; - const name = nameResult.value as string; - const isAdmin = isAdminResult.value as boolean; - const profile_image = profileImageResult.value ?? undefined; - - // Check if user with this email already exists in DB - const existingUser = await db - .selectFrom('branch.users') - .where('email', '=', email) - .selectAll() - .executeTakeFirst(); - - if (existingUser) { - return json(409, { message: 'User with this email already exists' }); - } - - // Create user in Cognito via AdminCreateUser — sends invite email with temp password - let cognitoSub: string; - try { - const cognitoResponse = await cognitoClient.send(new AdminCreateUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email, - DesiredDeliveryMediums: ['EMAIL'], - UserAttributes: [ - { Name: 'email', Value: email }, - { Name: 'email_verified', Value: 'true' }, - { Name: 'name', Value: name }, - ], - })); - const sub = cognitoResponse.User?.Attributes?.find(a => a.Name === 'sub')?.Value; - if (!sub) throw new Error('No sub returned from AdminCreateUser'); - cognitoSub = sub; - } catch (err: any) { - console.error('Cognito AdminCreateUser error:', err); - if (err.name === 'UsernameExistsException') { - return json(409, { message: 'User with this email already exists' }); - } - return json(500, { message: 'Failed to create user in authentication service' }); - } - - // Insert into database with cognito_sub - try { - await db - .insertInto('branch.users') - .values({ cognito_sub: cognitoSub, email, name, is_admin: isAdmin, profile_image }) - .execute(); - } catch (err: any) { - console.error('Database insert error:', err); - // Rollback: delete Cognito user to keep systems in sync - try { - await cognitoClient.send(new AdminDeleteUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email, - })); - console.log('Rolled back Cognito user after database failure'); - } catch (rollbackErr) { - console.error('Failed to rollback Cognito user:', rollbackErr); - } - return json(500, { message: 'Failed to create user' }); - } - - return json(201, { - ok: true, - route: 'POST /users', - pathParams: {}, - body: { - email, - name, - isAdmin, - }, - }); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} \ No newline at end of file +export const handler = (event: any) => dispatch(event, { prefix: 'users', routes }); diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index 378c2932..16898822 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" @@ -45,6 +46,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -822,6 +839,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 0e999510..be0613dc 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -26,6 +26,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" diff --git a/apps/backend/lambdas/users/routes.ts b/apps/backend/lambdas/users/routes.ts new file mode 100644 index 00000000..f0c3018f --- /dev/null +++ b/apps/backend/lambdas/users/routes.ts @@ -0,0 +1,12 @@ +import type { Route } from '@branch/lambda-http'; +import { listUsers, getUser, patchUser, deleteUser, createUser } from './controllers/users'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'GET', pattern: '/users', handler: listUsers }, + { method: 'GET', pattern: '/users/:userId', handler: getUser }, + { method: 'PATCH', pattern: '/users/:userId', handler: patchUser }, + { method: 'DELETE', pattern: '/users/:userId', handler: deleteUser }, + { method: 'POST', pattern: '/users', handler: createUser }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/users/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index 077e5b3c..2d93c455 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { dispatch, json, type Route } from '@branch/lambda-http'; // Mock the database module BEFORE importing handler jest.mock('../db'); @@ -551,3 +552,30 @@ describe('PATCH /users/{userId} unit tests', () => { }); }); }); + +describe('route precedence', () => { + test('a literal segment route wins over a same-shaped :param route placed after it', async () => { + const literalHandler = jest.fn(async () => json(200, { matched: 'literal' })); + const paramHandler = jest.fn(async () => json(200, { matched: 'param' })); + + const routes: Route[] = [ + { method: 'GET', pattern: '/users/me', handler: literalHandler }, + { method: 'GET', pattern: '/users/:userId', handler: paramHandler }, + ]; + + const literalRes = await dispatch( + { rawPath: '/users/me', requestContext: { http: { method: 'GET' } } }, + { prefix: 'users', routes }, + ); + expect(JSON.parse(literalRes.body)).toEqual({ matched: 'literal' }); + expect(literalHandler).toHaveBeenCalledTimes(1); + expect(paramHandler).not.toHaveBeenCalled(); + + const paramRes = await dispatch( + { rawPath: '/users/42', requestContext: { http: { method: 'GET' } } }, + { prefix: 'users', routes }, + ); + expect(JSON.parse(paramRes.body)).toEqual({ matched: 'param' }); + expect(paramHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/lambdas/users/tsconfig.json b/apps/backend/lambdas/users/tsconfig.json index d35b2baa..dc8dacce 100644 --- a/apps/backend/lambdas/users/tsconfig.json +++ b/apps/backend/lambdas/users/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } From e6f2144ba39b2666451309a2654a5cf091054c67 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 13:57:41 -0400 Subject: [PATCH 03/20] refactor(donors): adopt @branch/lambda-http declarative route table Replace the if-chain in handler.ts with a Route[] table dispatched via @branch/lambda-http's dispatch(). handler.ts is now a one-liner; route logic moved into controllers/donors.ts (GET/POST /donors, DELETE /donors/:id) and controllers/donations.ts (GET/POST /donors/donations, DELETE /donors/donations/:id), in the same order as the original if chain. No services/ layer added, per this lambda's existing shape. Local json() removed in favor of the shared one; auth stays manual (authenticateRequest + custom 401/403 messages) since this lambda's authorization messages don't match @branch/lambda-http's generic requireAuth reasons. ROUTES-START/END markers moved into routes.ts, now bracketing the route table entries. Added @branch/lambda-http as a dependency and regenerated package-lock.json. Added one test asserting GET /donors/donations reaches the donations controller rather than a donor-id route. No behavior change: same status codes, messages, and validation order. Verified via tsc --noEmit and jest (--runInBand to avoid DB contention with sibling lambda test runs): 51 passed, 1 pre-existing failure (health test requires a live dev-server on :3000, fails identically on main). --- .../lambdas/donors/controllers/donations.ts | 194 ++++++++++ .../lambdas/donors/controllers/donors.ts | 136 +++++++ apps/backend/lambdas/donors/handler.ts | 351 +----------------- apps/backend/lambdas/donors/package-lock.json | 21 ++ apps/backend/lambdas/donors/package.json | 1 + apps/backend/lambdas/donors/routes.ts | 14 + .../lambdas/donors/test/donors.test.ts | 11 + apps/backend/lambdas/donors/tsconfig.json | 2 +- 8 files changed, 381 insertions(+), 349 deletions(-) create mode 100644 apps/backend/lambdas/donors/controllers/donations.ts create mode 100644 apps/backend/lambdas/donors/controllers/donors.ts create mode 100644 apps/backend/lambdas/donors/routes.ts diff --git a/apps/backend/lambdas/donors/controllers/donations.ts b/apps/backend/lambdas/donors/controllers/donations.ts new file mode 100644 index 00000000..8bdb7dba --- /dev/null +++ b/apps/backend/lambdas/donors/controllers/donations.ts @@ -0,0 +1,194 @@ +import type { RouteCtx } from '@branch/lambda-http'; +import { json } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; + +// GET /donors/donations +export async function getDonations({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.project_donations') + .select(db.fn.count('donation_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .orderBy('donation_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donations, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .execute(); + return json(200, { data: donations }); +} + +// POST /donors/donations +export async function createDonation({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const body = event.body ? (JSON.parse(event.body) as Record) : {}; + const { donor_id, project_id, amount } = body; + + if (donor_id === undefined || project_id === undefined || amount === undefined) { + return json(400, { message: 'donor_id, project_id, and amount are required' }); + } + // Numeric fields arrive as strings from form posts; amount is NUMERIC(12,2) + const num = (value: unknown) => + typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN; + const donorId = num(donor_id); + const projectId = num(project_id); + const donationAmount = num(amount); + + if (!Number.isInteger(donorId) || donorId < 1) { + return json(400, { message: 'donor_id must be a positive integer' }); + } + if (!Number.isInteger(projectId) || projectId < 1) { + return json(400, { message: 'project_id must be a positive integer' }); + } + if (!isFinite(donationAmount) || donationAmount <= 0) { + return json(400, { message: 'amount must be a positive number' }); + } + // Check user is admin or a member of the project + if (!authContext.user?.isAdmin) { + const userId = authContext.user!.userId as number; + const membership = await db + .selectFrom('branch.project_memberships') + .select('membership_id') + .where('project_id', '=', projectId) + .where('user_id', '=', userId) + .executeTakeFirst(); + + if (!membership) { + return json(403, { message: 'You must be a member' }); + } + } + + // Checked after the membership check so project existence isn't leaked to non-members + const donor = await db + .selectFrom('branch.donors') + .select('donor_id') + .where('donor_id', '=', donorId) + .executeTakeFirst(); + + if (!donor) { + return json(404, { message: 'Donor not found' }); + } + + const project = await db + .selectFrom('branch.projects') + .select('project_id') + .where('project_id', '=', projectId) + .executeTakeFirst(); + + if (!project) { + return json(404, { message: 'Project not found' }); + } + + try { + const donation = await db + .insertInto('branch.project_donations') + .values({ + donor_id: donorId, + project_id: projectId, + amount: donationAmount, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return json(201, { data: donation }); + } catch (err: any) { + if (err?.code === '23505') { + return json(409, { message: 'A donation from this donor to this project already exists' }); + } + if (err?.code === '23503') { + return json(404, { message: 'Donor or project not found' }); + } + throw err; + } +} + +// DELETE /donors/donations/{id} +export async function deleteDonation({ event, params }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const id = params.id; + if (!id || !/^\d+$/.test(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const donation = await db + .selectFrom('branch.project_donations') + .where('donation_id', '=', Number(id)) + .selectAll() + .executeTakeFirst(); + + if (!donation) { + return json(404, { message: 'Donation not found' }); + } + + if (!authContext.user?.isAdmin) { + const userId = authContext.user!.userId as number; + const membership = await db + .selectFrom('branch.project_memberships') + .select('membership_id') + .where('project_id', '=', donation.project_id) + .where('user_id', '=', userId) + .executeTakeFirst(); + + if (!membership) { + return json(403, { message: 'You must be a member of this project to delete this donation' }); + } + } + + const deleted = await db.deleteFrom('branch.project_donations').where('donation_id', '=', Number(id)).execute(); + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'Donation not found' }); + } + + return json(200, { ok: true, route: 'DELETE /donations/{id}', pathParams: { id } }); +} diff --git a/apps/backend/lambdas/donors/controllers/donors.ts b/apps/backend/lambdas/donors/controllers/donors.ts new file mode 100644 index 00000000..58cdad12 --- /dev/null +++ b/apps/backend/lambdas/donors/controllers/donors.ts @@ -0,0 +1,136 @@ +import type { RouteCtx } from '@branch/lambda-http'; +import { json } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; +import { DonorValidationUtils } from '../validation-utils'; + +// GET /donors +export async function getDonors({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.donors') + .select(db.fn.count('donor_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donors = await db + .selectFrom('branch.donors') + .selectAll() + .orderBy('donor_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donors, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const donors = await db.selectFrom('branch.donors').selectAll().execute(); + return json(200, { data: donors }); +} + +// POST /donors +export async function createDonor({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + + if (!user) { + return json(401, { message: 'Authentication required' }); + } + if (!user.isAdmin) { + return json(403, { message: 'Only admins can create donors' }); + } + + const body = event.body ? (JSON.parse(event.body) as Record) : {}; + + // Validate input + const validationResult = DonorValidationUtils.validateDonorInput(body); + if (validationResult instanceof Error) { + return json(400, { message: validationResult.message }); + } + + const { organization, contactName, contactEmail } = validationResult; + + // Insert donor with authenticated user as entered_by + try { + await db + .insertInto('branch.donors') + .values({ + organization, + contact_name: contactName ?? null, + contact_email: contactEmail ?? null, + }) + .executeTakeFirst(); + } catch (err) { + console.error('Database insert error:', err); + return json(500, { message: 'Failed to create donor' }); + } + + return json(201, { + ok: true, + route: 'POST /donors', + body: { + organization, + contactName: contactName ?? null, + contactEmail: contactEmail ?? null, + }, + }); +} + +// DELETE /donors/{id} +export async function deleteDonor({ event, params }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const id = params.id; + if (!id || !/^\d+$/.test(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + if (!authContext.user?.isAdmin) { + return json(403, { message: 'Only admins can delete donors' }); + } + + const deleted = await db.deleteFrom('branch.donors').where('donor_id', '=', Number(id)).execute(); + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'Donor not found' }); + } + + return json(200, { ok: true, route: 'DELETE /donors/{id}', pathParams: { id } }); +} diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index 55bab5de..2f158b0a 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -1,349 +1,4 @@ -import { APIGatewayProxyResult } from 'aws-lambda'; -import db from './db'; -import { authenticateRequest } from './auth'; -import { DonorValidationUtils } from './validation-utils'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /donors[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/donors(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated) { - return json(401, { message: 'Authentication required' }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // GET /donors - if (rawPath === '/' && method === 'GET') { - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.donors') - .select(db.fn.count('donor_id').as('count')) - .executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const donors = await db - .selectFrom('branch.donors') - .selectAll() - .orderBy('donor_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - - return json(200, { - data: donors, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const donors = await db.selectFrom('branch.donors').selectAll().execute(); - return json(200, { data: donors }); - } - - // GET /donations - if ((normalizedPath === '/donations') && method === 'GET') { - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.project_donations') - .select(db.fn.count('donation_id').as('count')) - .executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const donations = await db - .selectFrom('branch.project_donations') - .selectAll() - .orderBy('donation_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - - return json(200, { - data: donations, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const donations = await db - .selectFrom('branch.project_donations') - .selectAll() - .execute(); - return json(200, { data: donations }); - } - - // POST /donations - if (normalizedPath === '/donations' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { donor_id, project_id, amount } = body; - - if (donor_id === undefined || project_id === undefined || amount === undefined) { - return json(400, { message: 'donor_id, project_id, and amount are required' }); - } - // Numeric fields arrive as strings from form posts; amount is NUMERIC(12,2) - const num = (value: unknown) => - typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN; - const donorId = num(donor_id); - const projectId = num(project_id); - const donationAmount = num(amount); - - if (!Number.isInteger(donorId) || donorId < 1) { - return json(400, { message: 'donor_id must be a positive integer' }); - } - if (!Number.isInteger(projectId) || projectId < 1) { - return json(400, { message: 'project_id must be a positive integer' }); - } - if (!isFinite(donationAmount) || donationAmount <= 0) { - return json(400, { message: 'amount must be a positive number' }); - } - // Check user is admin or a member of the project - if (!authContext.user?.isAdmin) { - const userId = authContext.user!.userId as number; - const membership = await db - .selectFrom('branch.project_memberships') - .select('membership_id') - .where('project_id', '=', projectId) - .where('user_id', '=', userId) - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'You must be a member' }); - } - } - - // Checked after the membership check so project existence isn't leaked to non-members - const donor = await db - .selectFrom('branch.donors') - .select('donor_id') - .where('donor_id', '=', donorId) - .executeTakeFirst(); - - if (!donor) { - return json(404, { message: 'Donor not found' }); - } - - const project = await db - .selectFrom('branch.projects') - .select('project_id') - .where('project_id', '=', projectId) - .executeTakeFirst(); - - if (!project) { - return json(404, { message: 'Project not found' }); - } - - try { - const donation = await db - .insertInto('branch.project_donations') - .values({ - donor_id: donorId, - project_id: projectId, - amount: donationAmount, - }) - .returningAll() - .executeTakeFirstOrThrow(); - - return json(201, { data: donation }); - } catch (err: any) { - if (err?.code === '23505') { - return json(409, { message: 'A donation from this donor to this project already exists' }); - } - if (err?.code === '23503') { - return json(404, { message: 'Donor or project not found' }); - } - throw err; - } - } - - // POST /donors - if ((normalizedPath === '/' || normalizedPath === '' || normalizedPath === '/donors') && method === 'POST') { - // Authenticate the request - const { user } = authContext; - - if (!user) { - return json(401, { message: 'Authentication required' }); - } - if (!user.isAdmin) { - return json(403, { message: 'Only admins can create donors' }); - } - - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // Validate input - const validationResult = DonorValidationUtils.validateDonorInput(body); - if (validationResult instanceof Error) { - return json(400, { message: validationResult.message }); - } - - const { organization, contactName, contactEmail } = validationResult; - - // Insert donor with authenticated user as entered_by - try { - await db - .insertInto('branch.donors') - .values({ - organization, - contact_name: contactName ?? null, - contact_email: contactEmail ?? null, - }) - .executeTakeFirst(); - } catch (err) { - console.error('Database insert error:', err); - return json(500, { message: 'Failed to create donor' }); - } - - return json(201, { - ok: true, - route: 'POST /donors', - body: { - organization, - contactName: contactName ?? null, - contactEmail: contactEmail ?? null, - }, - }); - } - - // DELETE /donors/{id} - if (/^\/[^\/]+$/.test(normalizedPath) && method === 'DELETE') { - const id = normalizedPath.split('/')[1]; - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - if (!authContext.user?.isAdmin) { - return json(403, { message: 'Only admins can delete donors' }); - } - - const deleted = await db.deleteFrom('branch.donors').where('donor_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Donor not found' }); - } - - return json(200, { ok: true, route: 'DELETE /donors/{id}', pathParams: { id } }); - - } - - // DELETE /donations/{id} - if (normalizedPath.startsWith('/donations/') && normalizedPath.split('/').length === 3 && method === 'DELETE') { - const id = normalizedPath.split('/')[2]; - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - const donation = await db - .selectFrom('branch.project_donations') - .where('donation_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!donation) { - return json(404, { message: 'Donation not found' }); - } - - if (!authContext.user?.isAdmin) { - const userId = authContext.user!.userId as number; - const membership = await db - .selectFrom('branch.project_memberships') - .select('membership_id') - .where('project_id', '=', donation.project_id) - .where('user_id', '=', userId) - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'You must be a member of this project to delete this donation' }); - } - } - - const deleted = await db.deleteFrom('branch.project_donations').where('donation_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Donation not found' }); - } - - return json(200, { ok: true, route: 'DELETE /donations/{id}', pathParams: { id } }); - } - - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +export const handler = (event: any) => dispatch(event, { prefix: 'donors', routes }); diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index 66b5b492..32fcad09 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.17.2" @@ -43,6 +44,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -548,6 +565,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 252aeb23..0e96a436 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.17.2" diff --git a/apps/backend/lambdas/donors/routes.ts b/apps/backend/lambdas/donors/routes.ts new file mode 100644 index 00000000..ae3d6170 --- /dev/null +++ b/apps/backend/lambdas/donors/routes.ts @@ -0,0 +1,14 @@ +import type { Route } from '@branch/lambda-http'; +import { getDonors, createDonor, deleteDonor } from './controllers/donors'; +import { getDonations, createDonation, deleteDonation } from './controllers/donations'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'GET', pattern: '/donors', handler: getDonors }, + { method: 'GET', pattern: '/donors/donations', handler: getDonations }, + { method: 'POST', pattern: '/donors/donations', handler: createDonation }, + { method: 'POST', pattern: '/donors', handler: createDonor }, + { method: 'DELETE', pattern: '/donors/:id', handler: deleteDonor }, + { method: 'DELETE', pattern: '/donors/donations/:id', handler: deleteDonation }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index b739aef4..82edf88b 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -204,6 +204,17 @@ describe("Donor API with data", () => { expect(body.data.length).toBe(3); }); + test("GET /donors/donations reaches the donations controller, not the /donors/:id route", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donors/donations')); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(3); + expect(body.data[0]).toHaveProperty('donation_id'); + }); + test("GET /donations with page and limit returns paginated response", async () => { mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); const res = await handler(createEvent('GET', '/donations', undefined, { page: '1', limit: '1' })); diff --git a/apps/backend/lambdas/donors/tsconfig.json b/apps/backend/lambdas/donors/tsconfig.json index 7e7cce09..a7c5d558 100644 --- a/apps/backend/lambdas/donors/tsconfig.json +++ b/apps/backend/lambdas/donors/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts", "jest.config.js"], + "include": ["*.ts", "controllers/**/*.ts", "jest.config.js"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } From c2994f6ebb1aaea2d4ed63aa35d2c41c59e2209e Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 13:59:43 -0400 Subject: [PATCH 04/20] refactor(reports): adopt @branch/lambda-http declarative route table Replaces the if-chain in handler.ts with a Route[] table (routes.ts) and one RouteHandler per route (controllers/reports.ts). handler.ts is now a thin `dispatch(event, { prefix: 'reports', routes })`. - Local json() and the local async requireAuth() are gone; dispatch provides json/OPTIONS/health/404/500 centrally, and createAuthGuard(authenticateRequest) replaces the local requireAuth, preserving its exact 401 "Authentication required" message. - Route order preserved from the original if-chain: POST /generate and GET /upload-url stay ahead of the /:id pattern they'd otherwise be swallowed by (both are 2-segment paths, same as /reports/:id). - REPORT_ID_ROUTE/REPORT_DOWNLOAD_ROUTE's \d+ constraint is now an explicit numeric check in getReport/deleteReport/downloadReport, so a non-numeric :id still falls through to the same 404 instead of being looked up as a report id. - report-service.ts is untouched; controllers still parse/validate/ respond and delegate to it. S3 presigning stays in the controllers, matching where it lived in handler.ts. - Added two route-precedence unit tests (GET /reports/upload-url and POST /reports/generate each reach their own controller, not /reports/:id or the generic POST /reports controller). - ROUTES-START/END markers moved into routes.ts around the route array. No behaviour change: same status codes, messages, validation order, and S3/TTL values as before. Co-Authored-By: Claude Sonnet 5 --- .../lambdas/reports/controllers/reports.ts | 334 +++++++++++++++ apps/backend/lambdas/reports/handler.ts | 386 +----------------- .../backend/lambdas/reports/package-lock.json | 21 + apps/backend/lambdas/reports/package.json | 1 + apps/backend/lambdas/reports/routes.ts | 22 + .../lambdas/reports/test/reports.unit.test.ts | 33 ++ apps/backend/lambdas/reports/tsconfig.json | 2 +- 7 files changed, 415 insertions(+), 384 deletions(-) create mode 100644 apps/backend/lambdas/reports/controllers/reports.ts create mode 100644 apps/backend/lambdas/reports/routes.ts diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts new file mode 100644 index 00000000..c6bea6e1 --- /dev/null +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -0,0 +1,334 @@ +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { json, parseBody, createAuthGuard } from '@branch/lambda-http'; +import type { RouteHandler } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; +import { + checkProjectAccess, + fetchReportData, + generatePdf, + generateDocx, + uploadToS3, + saveReportRecord, + objectUrlFor, + keyFromObjectUrl, + reportKeyPrefix, +} from '../report-service'; + +const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); +const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; + +const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const; +const MIME_TYPES: Record = { + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +}; +const REPORT_TYPES = ['technical', 'narrative'] as const; +const DOWNLOAD_URL_TTL_SECONDS = 900; + +type FileType = typeof ALLOWED_EXTENSIONS[number]; +type ReportType = typeof REPORT_TYPES[number]; + +const guard = createAuthGuard(authenticateRequest); + +// Numeric-only id, mirroring the old REPORT_ID_ROUTE/REPORT_DOWNLOAD_ROUTE regexes +// so a non-numeric :id falls through to the same 404 as an unmatched route. +function notFoundUnlessNumericId(id: string, path: string, method: string) { + return /^\d+$/.test(id) ? undefined : json(404, { message: 'Not Found', path, method }); +} + +export const generateReport: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const body = event.body ? JSON.parse(event.body) as Record : {}; + + const projectId = body.project_id; + if (projectId === undefined || projectId === null) { + return json(400, { message: 'project_id is required' }); + } + if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { + return json(400, { message: 'project_id must be a positive integer' }); + } + + const fileType = (body.file_type ?? 'pdf') as FileType; + if (!ALLOWED_EXTENSIONS.includes(fileType)) { + return json(400, { message: `file_type must be one of: ${ALLOWED_EXTENSIONS.join(', ')}` }); + } + + const reportType = (body.report_type ?? 'technical') as ReportType; + if (!REPORT_TYPES.includes(reportType)) { + return json(400, { message: `report_type must be one of: ${REPORT_TYPES.join(', ')}` }); + } + + const reportData = await fetchReportData(projectId); + if (!reportData) { + return json(404, { message: 'Project not found' }); + } + + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); + if (!hasAccess) { + return json(403, { message: 'You do not have access to generate reports for this project' }); + } + + let fileBuffer: Buffer; + try { + fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); + } catch (err) { + console.error('Report generation error:', err); + return json(500, { message: 'Failed to generate report' }); + } + + let objectUrl: string; + try { + objectUrl = await uploadToS3(fileBuffer, projectId, fileType); + } catch (err) { + console.error('S3 upload error:', err); + return json(500, { message: 'Failed to upload report' }); + } + + const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; + const record = await saveReportRecord(projectId, objectUrl, title, reportType); + + return json(201, { + ok: true, + report_id: record.report_id, + object_url: record.object_url, + report_type: record.report_type, + file_type: fileType, + }); +}; + +export const listReports: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + const projectIdStr = queryParams.projectId as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + if (projectIdStr !== undefined) { + if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst() + : await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const reports = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute() + : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); + + return json(200, { + data: reports, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const reports = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute() + : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); + + return json(200, { data: reports }); +}; + +export const getUploadUrl: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const queryParams = event.queryStringParameters || {}; + const { fileName, projectId: projectIdStr } = queryParams; + + if (!fileName || typeof fileName !== 'string') { + return json(400, { message: 'fileName is required' }); + } + const safeFileName = fileName.replace(/^.*[\\/]/, '').replace(/[^A-Za-z0-9._-]/g, '_'); + if (!/[A-Za-z0-9]/.test(safeFileName)) { + return json(400, { message: 'Invalid fileName' }); + } + const ext = safeFileName.split('.').pop()?.toLowerCase() ?? ''; + if (!ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) { + return json(400, { message: 'Only PDF and DOCX files are supported' }); + } + if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + const projectId = parseInt(projectIdStr, 10); + + const projectExists = await db.selectFrom('branch.projects') + .where('project_id', '=', projectId) + .select('project_id') + .executeTakeFirst(); + if (!projectExists) return json(404, { message: 'Project not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to upload reports for this project' }); + } + + const key = `${reportKeyPrefix(projectId)}${Date.now()}-${safeFileName}`; + const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + ContentType: MIME_TYPES[ext], + }), { expiresIn: 3600 }); + + return json(200, { uploadUrl, objectUrl: objectUrlFor(key) }); +}; + +export const createReport: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const body = parseBody(event); + if (body === null) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { title, projectId, objectUrl, reportType } = body; + + if (!title || typeof title !== 'string' || title.trim().length === 0) { + return json(400, { message: 'title is required' }); + } + if (!projectId || typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + if (!objectUrl || typeof objectUrl !== 'string') { + return json(400, { message: 'objectUrl is required' }); + } + const postedKey = keyFromObjectUrl(objectUrl); + if (!postedKey) { + return json(400, { message: 'objectUrl must point at the reports bucket' }); + } + const resolvedReportType: ReportType = (reportType && REPORT_TYPES.includes(reportType as ReportType)) ? reportType as ReportType : 'technical'; + + const projectExists = await db.selectFrom('branch.projects') + .where('project_id', '=', projectId as number) + .select('project_id') + .executeTakeFirst(); + if (!projectExists) return json(404, { message: 'Project not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, projectId as number, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to upload reports for this project' }); + } + + // Checked after authorization: the key must sit under this project's prefix, + // or a caller with access to one project could register another project's + // object and then read it back through GET /reports/{id}/download. + if (!postedKey.startsWith(reportKeyPrefix(projectId))) { + return json(400, { message: "objectUrl must point at this project's prefix in the reports bucket" }); + } + + const report = await db + .insertInto('branch.reports') + .values({ project_id: projectId, title: (title as string).trim(), object_url: objectUrl as string, report_type: resolvedReportType }) + .returningAll() + .executeTakeFirst(); + + return json(201, report); +}; + +export const downloadReport: RouteHandler = async ({ event, params, path, method }) => { + const notFound = notFoundUnlessNumericId(params.id, path, method); + if (notFound) return notFound; + const id = params.id; + + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to this report' }); + } + + const key = keyFromObjectUrl(report.object_url); + if (!key || !key.startsWith(reportKeyPrefix(report.project_id))) { + return json(409, { message: 'Report is not stored in the reports bucket' }); + } + + const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + }), { expiresIn: DOWNLOAD_URL_TTL_SECONDS }); + + return json(200, { downloadUrl, expiresIn: DOWNLOAD_URL_TTL_SECONDS }); +}; + +export const getReport: RouteHandler = async ({ event, params, path, method }) => { + const notFound = notFoundUnlessNumericId(params.id, path, method); + if (notFound) return notFound; + const id = params.id; + + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to this report' }); + } + + return json(200, { ok: true, route: 'GET /reports/{id}', pathParams: { id }, body: report }); +}; + +export const deleteReport: RouteHandler = async ({ event, params, path, method }) => { + const notFound = notFoundUnlessNumericId(params.id, path, method); + if (notFound) return notFound; + const id = params.id; + + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to delete this report' }); + } + + const deleted = await db.deleteFrom('branch.reports').where('report_id', '=', Number(id)).execute(); + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'Report not found' }); + } + + return json(200, { ok: true, route: 'DELETE /reports/{id}', pathParams: { id } }); +}; diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index c9b1226f..05071e06 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -1,384 +1,4 @@ -import { APIGatewayProxyResult } from 'aws-lambda'; -import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import db from './db'; -import { authenticateRequest } from './auth'; -import { - checkProjectAccess, - fetchReportData, - generatePdf, - generateDocx, - uploadToS3, - saveReportRecord, - objectUrlFor, - keyFromObjectUrl, - reportKeyPrefix, -} from './report-service'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); -const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; - -const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const; -const MIME_TYPES: Record = { - pdf: 'application/pdf', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', -}; -const REPORT_TYPES = ['technical', 'narrative'] as const; -const DOWNLOAD_URL_TTL_SECONDS = 900; -const REPORT_ID_ROUTE = /^\/(\d+)$/; -const REPORT_DOWNLOAD_ROUTE = /^(?:\/reports)?\/(\d+)\/download$/; - -async function requireAuth( - event: any -): Promise<{ user: NonNullable>['user']> } | { errorResponse: APIGatewayProxyResult }> { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return { errorResponse: json(401, { message: 'Authentication required' }) }; - } - return { user: authContext.user }; -} - -type FileType = typeof ALLOWED_EXTENSIONS[number]; -type ReportType = typeof REPORT_TYPES[number]; - -export const handler = async (event: any): Promise => { - try { - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /reports[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/reports(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // POST /reports/generate - if ((normalizedPath === '/reports/generate' || normalizedPath === '/generate') && method === 'POST') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - const body = event.body ? JSON.parse(event.body) as Record : {}; - - const projectId = body.project_id; - if (projectId === undefined || projectId === null) { - return json(400, { message: 'project_id is required' }); - } - if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { - return json(400, { message: 'project_id must be a positive integer' }); - } - - const fileType = (body.file_type ?? 'pdf') as FileType; - if (!ALLOWED_EXTENSIONS.includes(fileType)) { - return json(400, { message: `file_type must be one of: ${ALLOWED_EXTENSIONS.join(', ')}` }); - } - - const reportType = (body.report_type ?? 'technical') as ReportType; - if (!REPORT_TYPES.includes(reportType)) { - return json(400, { message: `report_type must be one of: ${REPORT_TYPES.join(', ')}` }); - } - - const reportData = await fetchReportData(projectId); - if (!reportData) { - return json(404, { message: 'Project not found' }); - } - - const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); - if (!hasAccess) { - return json(403, { message: 'You do not have access to generate reports for this project' }); - } - - let fileBuffer: Buffer; - try { - fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); - } catch (err) { - console.error('Report generation error:', err); - return json(500, { message: 'Failed to generate report' }); - } - - let objectUrl: string; - try { - objectUrl = await uploadToS3(fileBuffer, projectId, fileType); - } catch (err) { - console.error('S3 upload error:', err); - return json(500, { message: 'Failed to upload report' }); - } - - const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; - const record = await saveReportRecord(projectId, objectUrl, title, reportType); - - return json(201, { - ok: true, - report_id: record.report_id, - object_url: record.object_url, - report_type: record.report_type, - file_type: fileType, - }); - } - - // GET /reports - if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - const projectIdStr = queryParams.projectId as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - if (projectIdStr !== undefined) { - if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); - - return json(200, { - data: reports, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); - - return json(200, { data: reports }); - } - - // GET /reports/upload-url - if ((normalizedPath === '/reports/upload-url' || normalizedPath === '/upload-url') && method === 'GET') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const queryParams = event.queryStringParameters || {}; - const { fileName, projectId: projectIdStr } = queryParams; - - if (!fileName || typeof fileName !== 'string') { - return json(400, { message: 'fileName is required' }); - } - const safeFileName = fileName.replace(/^.*[\\/]/, '').replace(/[^A-Za-z0-9._-]/g, '_'); - if (!/[A-Za-z0-9]/.test(safeFileName)) { - return json(400, { message: 'Invalid fileName' }); - } - const ext = safeFileName.split('.').pop()?.toLowerCase() ?? ''; - if (!ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) { - return json(400, { message: 'Only PDF and DOCX files are supported' }); - } - if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - const projectId = parseInt(projectIdStr, 10); - - const projectExists = await db.selectFrom('branch.projects') - .where('project_id', '=', projectId) - .select('project_id') - .executeTakeFirst(); - if (!projectExists) return json(404, { message: 'Project not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to upload reports for this project' }); - } - - const key = `${reportKeyPrefix(projectId)}${Date.now()}-${safeFileName}`; - const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - ContentType: MIME_TYPES[ext], - }), { expiresIn: 3600 }); - - return json(200, { uploadUrl, objectUrl: objectUrlFor(key) }); - } - - // POST /reports - if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - let body: Record; - try { - body = event.body ? JSON.parse(event.body) : {}; - } catch { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { title, projectId, objectUrl, reportType } = body; - - if (!title || typeof title !== 'string' || title.trim().length === 0) { - return json(400, { message: 'title is required' }); - } - if (!projectId || typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - if (!objectUrl || typeof objectUrl !== 'string') { - return json(400, { message: 'objectUrl is required' }); - } - const postedKey = keyFromObjectUrl(objectUrl); - if (!postedKey) { - return json(400, { message: 'objectUrl must point at the reports bucket' }); - } - const resolvedReportType: ReportType = (reportType && REPORT_TYPES.includes(reportType as ReportType)) ? reportType as ReportType : 'technical'; - - const projectExists = await db.selectFrom('branch.projects') - .where('project_id', '=', projectId as number) - .select('project_id') - .executeTakeFirst(); - if (!projectExists) return json(404, { message: 'Project not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, projectId as number, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to upload reports for this project' }); - } - - // Checked after authorization: the key must sit under this project's prefix, - // or a caller with access to one project could register another project's - // object and then read it back through GET /reports/{id}/download. - if (!postedKey.startsWith(reportKeyPrefix(projectId))) { - return json(400, { message: "objectUrl must point at this project's prefix in the reports bucket" }); - } - - const report = await db - .insertInto('branch.reports') - .values({ project_id: projectId, title: (title as string).trim(), object_url: objectUrl as string, report_type: resolvedReportType }) - .returningAll() - .executeTakeFirst(); - - return json(201, report); - } - - // GET /reports/{id}/download - const downloadMatch = method === 'GET' ? normalizedPath.match(REPORT_DOWNLOAD_ROUTE) : null; - if (downloadMatch) { - const id = downloadMatch[1]; - - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); - if (!report) return json(404, { message: 'Report not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to this report' }); - } - - const key = keyFromObjectUrl(report.object_url); - if (!key || !key.startsWith(reportKeyPrefix(report.project_id))) { - return json(409, { message: 'Report is not stored in the reports bucket' }); - } - - const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({ - Bucket: BUCKET, - Key: key, - }), { expiresIn: DOWNLOAD_URL_TTL_SECONDS }); - - return json(200, { downloadUrl, expiresIn: DOWNLOAD_URL_TTL_SECONDS }); - } - - // GET /reports/{id} - const getIdMatch = method === 'GET' ? normalizedPath.match(REPORT_ID_ROUTE) : null; - if (getIdMatch) { - const id = getIdMatch[1]; - - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); - if (!report) return json(404, { message: 'Report not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to this report' }); - } - - return json(200, { ok: true, route: 'GET /reports/{id}', pathParams: { id }, body: report }); - } - - // DELETE /reports/{id} - const deleteIdMatch = method === 'DELETE' ? normalizedPath.match(REPORT_ID_ROUTE) : null; - if (deleteIdMatch) { - const id = deleteIdMatch[1]; - - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); - if (!report) return json(404, { message: 'Report not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to delete this report' }); - } - - const deleted = await db.deleteFrom('branch.reports').where('report_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Report not found' }); - } - - return json(200, { ok: true, route: 'DELETE /reports/{id}', pathParams: { id } }); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +export const handler = (event: any) => dispatch(event, { prefix: 'reports', routes }); diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index fc74f315..dfba4600 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", @@ -52,6 +53,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1458,6 +1475,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index f1262651..f53f7ec3 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -30,6 +30,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", diff --git a/apps/backend/lambdas/reports/routes.ts b/apps/backend/lambdas/reports/routes.ts new file mode 100644 index 00000000..6b5fdee4 --- /dev/null +++ b/apps/backend/lambdas/reports/routes.ts @@ -0,0 +1,22 @@ +import type { Route } from '@branch/lambda-http'; +import { + generateReport, + listReports, + getUploadUrl, + createReport, + downloadReport, + getReport, + deleteReport, +} from './controllers/reports'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'POST', pattern: '/reports/generate', handler: generateReport }, + { method: 'GET', pattern: '/reports', handler: listReports }, + { method: 'GET', pattern: '/reports/upload-url', handler: getUploadUrl }, + { method: 'POST', pattern: '/reports', handler: createReport }, + { method: 'GET', pattern: '/reports/:id/download', handler: downloadReport }, + { method: 'GET', pattern: '/reports/:id', handler: getReport }, + { method: 'DELETE', pattern: '/reports/:id', handler: deleteReport }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index c080a652..2b549575 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -414,6 +414,39 @@ describe('GET /reports/upload-url unit tests', () => { }); }); +describe('Route precedence', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + }); + + // /reports/upload-url and /reports/:id both have two path segments, so + // upload-url must be registered before :id or it gets swallowed as an id lookup. + test('GET /reports/upload-url reaches getUploadUrl, not the /reports/:id controller', async () => { + const res = await handler({ + rawPath: '/reports/upload-url', + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + }); + // getUploadUrl-specific validation, not the 404 a numeric-id check on "upload-url" would give. + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('fileName is required'); + }); + + test('POST /reports/generate reaches generateReport, not the generic POST /reports controller', async () => { + const res = await handler({ + rawPath: '/reports/generate', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + body: JSON.stringify({}), + }); + // generateReport-specific validation, not createReport's 'title is required'. + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('project_id is required'); + }); +}); + describe('POST /reports unit tests', () => { const fakeObjectUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/1/123-report.pdf'; diff --git a/apps/backend/lambdas/reports/tsconfig.json b/apps/backend/lambdas/reports/tsconfig.json index d35b2baa..dc8dacce 100644 --- a/apps/backend/lambdas/reports/tsconfig.json +++ b/apps/backend/lambdas/reports/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } From 42186f11530bf635fe73a368d6463d40f5a8598e Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 14:01:36 -0400 Subject: [PATCH 05/20] refactor(expenditures): move to declarative route table via @branch/lambda-http MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the if-chain in handler.ts with dispatch() + a routes.ts table, matching the shared @branch/lambda-http package adopted repo-wide. - handler.ts: now just `dispatch(event, { prefix: 'expenditures', routes })`. - routes.ts: ordered Route[] table, ROUTES-START/END markers preserved around the entries. Route order matches the original if-chain, notably keeping /expenditures/upload-url before /expenditures/:id. - controllers/expenditures.ts: one RouteHandler per route — validates input, calls the service layer, shapes the response. Same status codes, messages, and validation order as before. - services/expenditures.ts: Kysely queries, S3 presigning, and receiptKeyFromUrl, unchanged in behavior, just relocated. - Local json()/requireAuth() dropped in favor of the @branch/lambda-http exports (requireAuth's ADMIN gate on PATCH /expenditures/:id/status is now backed by the real @branch/lambda-auth checkAuthorization instead of the handler-local wrapper; identical logic). - package.json: added @branch/lambda-http as a dependency; package-lock.json regenerated via `npm install --legacy-peer-deps`. - test/expenditures.unit.test.ts: added a route-precedence regression test for GET /expenditures/upload-url vs /expenditures/:id. No behavior change: same status codes, response shapes, S3 TTLs, and content-type restriction as the previous if-chain. Co-Authored-By: Claude Sonnet 5 --- .../expenditures/controllers/expenditures.ts | 321 +++++++++++ apps/backend/lambdas/expenditures/handler.ts | 508 +----------------- .../lambdas/expenditures/package-lock.json | 21 + .../backend/lambdas/expenditures/package.json | 1 + apps/backend/lambdas/expenditures/routes.ts | 23 + .../expenditures/services/expenditures.ts | 105 ++++ .../test/expenditures.unit.test.ts | 13 + .../lambdas/expenditures/tsconfig.json | 2 +- 8 files changed, 488 insertions(+), 506 deletions(-) create mode 100644 apps/backend/lambdas/expenditures/controllers/expenditures.ts create mode 100644 apps/backend/lambdas/expenditures/routes.ts create mode 100644 apps/backend/lambdas/expenditures/services/expenditures.ts diff --git a/apps/backend/lambdas/expenditures/controllers/expenditures.ts b/apps/backend/lambdas/expenditures/controllers/expenditures.ts new file mode 100644 index 00000000..13d96b8d --- /dev/null +++ b/apps/backend/lambdas/expenditures/controllers/expenditures.ts @@ -0,0 +1,321 @@ +import type { RouteHandler } from '@branch/lambda-http'; +import { json, requireAuth } from '@branch/lambda-http'; +import { authenticateRequest } from '../auth'; +import { ExpenditureValidationUtils } from '../validation-utils'; +import * as expendituresService from '../services/expenditures'; + +function invalidId(id: string): boolean { + return !/^\d+$/.test(id) || parseInt(id, 10) < 1; +} + +// GET /expenditures +export const getExpenditures: RouteHandler = async ({ event }) => { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + const projectIdStr = queryParams.projectId as string | undefined; + + if (pageStr !== undefined && (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1)) { + return json(400, { message: 'page must be a positive integer' }); + } + + if (limitStr !== undefined && (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1)) { + return json(400, { message: 'limit must be a positive integer' }); + } + + if (projectIdStr !== undefined && (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1)) { + return json(400, { message: 'projectId must be a positive integer' }); + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + const totalItems = await expendituresService.countExpenditures(projectId); + const totalPages = Math.ceil(totalItems / limit); + const expenditures = await expendituresService.queryExpenditures(projectId, { limit, offset }); + + return json(200, { + data: expenditures, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const expenditures = await expendituresService.queryExpenditures(projectId); + return json(200, { data: expenditures }); +}; + +// POST /expenditures +export const createExpenditure: RouteHandler = async ({ event }) => { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + const body = event.body ? JSON.parse(event.body) as Record : {}; + + const validationResult = ExpenditureValidationUtils.validateExpenditureInput(body); + if (validationResult instanceof Error) { + return json(400, { message: validationResult.message }); + } + + const { projectID, amount, category, description, status, receiptUrl, spentOn } = validationResult; + + // Authorize: must be global admin, or Director/Admin on this project + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(projectID, user.userId!); + if (!membership || !['Director', 'Admin'].includes(membership.role)) { + return json(403, { message: 'Unable to create expenditure for this project' }); + } + } + + const project = await expendituresService.findProjectById(projectID); + if (!project) { + return json(404, { message: 'Project not found' }); + } + + try { + await expendituresService.insertExpenditure({ + project_id: projectID, + entered_by: user.userId!, + amount, + category: category ?? null, + description: description ?? null, + status, + receipt_url: receiptUrl ?? null, + spent_on: spentOn ? new Date(spentOn) : new Date(), + }); + } catch (err) { + console.error('Database insert error:', err); + return json(500, { message: 'Failed to create expenditure' }); + } + + return json(201, { + ok: true, + route: 'POST /expenditures', + body: { + projectID, + enteredBy: user.userId!, + amount, + category: category ?? null, + description: description ?? null, + status, + receiptUrl: receiptUrl ?? null, + spentOn: spentOn ?? new Date().toISOString().split('T')[0], + }, + }); +}; + +// GET /expenditures/upload-url — presigned PUT for a receipt PDF. +export const getUploadUrl: RouteHandler = async ({ event }) => { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const queryParams = event.queryStringParameters || {}; + const { fileName, projectId: projectIdStr } = queryParams; + + if (!fileName || typeof fileName !== 'string') { + return json(400, { message: 'fileName is required' }); + } + if (fileName.split('.').pop()?.toLowerCase() !== 'pdf') { + return json(400, { message: 'Only PDF receipts are supported' }); + } + if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + const projectId = parseInt(projectIdStr, 10); + + // Same authorization as POST /expenditures: you may only attach a receipt + // to a project you are allowed to file an expenditure against. + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(projectId, user.userId!); + if (!membership || !['Director', 'Admin'].includes(membership.role)) { + return json(403, { message: 'Unable to upload a receipt for this project' }); + } + } + + const { uploadUrl, objectUrl } = await expendituresService.presignUploadUrl(projectId, fileName); + return json(200, { uploadUrl, objectUrl }); +}; + +// GET /expenditures/{id}/receipt — presigned GET so the receipt can be read +// without the bucket being public. +export const getReceipt: RouteHandler = async ({ event, params }) => { + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) return json(404, { message: 'Expenditure not found' }); + + // Mirrors GET /expenditures/{id}: admin, or any membership on the project. + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(expenditure.project_id, user.userId!); + if (!membership) { + return json(403, { message: 'Unable to view this receipt' }); + } + } + + if (!expenditure.receipt_url) { + return json(404, { message: 'Expenditure has no receipt' }); + } + + const key = expendituresService.receiptKeyFromUrl(expenditure.receipt_url); + if (!key) { + return json(422, { message: 'Receipt is not stored in the receipts bucket' }); + } + + const downloadUrl = await expendituresService.presignReceiptDownload(key); + + return json(200, { + downloadUrl, + fileName: key.split('/').pop(), + }); +}; + +// GET /expenditures/{id} +export const getExpenditureById: RouteHandler = async ({ event, params }) => { + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) return json(404, { message: 'Expenditure not found' }); + + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(expenditure.project_id, user.userId!); + if (!membership) { + return json(403, { message: 'Unable to view this expenditure' }); + } + } + + // "Submitted By" in the review modal needs a name, not an id. + const submitter = expenditure.entered_by + ? await expendituresService.findUserName(expenditure.entered_by) + : undefined; + + const projectName = await expendituresService.findProjectName(expenditure.project_id); + + return json(200, { + ok: true, + route: 'GET /expenditures/{id}', + pathParams: { id }, + body: { + expenditureId: expenditure.expenditure_id, + projectId: expenditure.project_id, + projectName: projectName ?? null, + enteredBy: expenditure.entered_by, + submittedByName: submitter ?? null, + amount: expenditure.amount, + category: expenditure.category, + description: expenditure.description, + status: expenditure.status, + adminNotes: expenditure.admin_notes, + receiptUrl: expenditure.receipt_url, + spent_on: expenditure.spent_on, + createdAt: expenditure.created_at, + }, + }); +}; + +// DELETE /expenditures/{id} +export const deleteExpenditure: RouteHandler = async ({ event, params }) => { + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) { + return json(404, { message: 'Expenditure not found' }); + } + + // (mirrors POST endpoint) Authorize: must be global admin, or Director/Admin on this expenditure's project + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(expenditure.project_id, user.userId!); + if (!membership || !['Director', 'Admin'].includes(membership.role)) { + return json(403, { message: 'Unable to delete this expenditure' }); + } + } + + const numDeletedRows = await expendituresService.deleteExpenditureById(Number(id)); + if (numDeletedRows === 0n) { + return json(404, { message: 'Expenditure not found' }); + } + + return json(200, { ok: true, route: 'DELETE /expenditures/{id}', pathParams: { id } }); +}; + +// PATCH /expenditures/{id}/status — approve/decline (admin only) +export const patchExpenditureStatus: RouteHandler = async ({ event, params }) => { + const authContext = await authenticateRequest(event); + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const body = event.body ? JSON.parse(event.body) as Record : {}; + + const statusResult = ExpenditureValidationUtils.validateApprovalStatus(body.status); + if (statusResult instanceof Error) { + return json(400, { message: statusResult.message }); + } + + const adminNotesResult = ExpenditureValidationUtils.validateAdminNotes(body.adminNotes); + if (adminNotesResult instanceof Error) { + return json(400, { message: adminNotesResult.message }); + } + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) { + return json(404, { message: 'Expenditure not found' }); + } + + await expendituresService.updateExpenditureStatus(Number(id), statusResult, adminNotesResult); + const updated = await expendituresService.findExpenditureById(Number(id)); + + return json(200, { + ok: true, + route: 'PATCH /expenditures/{id}/status', + pathParams: { id }, + body: { + expenditureId: updated!.expenditure_id, + status: updated!.status, + adminNotes: updated!.admin_notes, + }, + }); +}; diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index 1166bf8e..3acdcab8 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -1,506 +1,4 @@ -import { APIGatewayProxyResult } from 'aws-lambda'; -import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import db from './db'; -import { ExpenditureValidationUtils } from './validation-utils'; -import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -const REGION = process.env.AWS_REGION ?? 'us-east-2'; -const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; -const s3 = new S3Client({ region: REGION }); - -// Receipts are PDFs only, matching the dropzone in AddExpenseModal. -const RECEIPT_CONTENT_TYPE = 'application/pdf'; - -// Receipts live in the same bucket as reports, under their own prefix. -function receiptKeyFromUrl(objectUrl: string): string | null { - const match = objectUrl.match(/^https:\/\/[^/]+\/(receipts\/.+)$/); - return match ? decodeURIComponent(match[1]) : null; -} - -function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { - const authCheck = checkAuthorization(authContext, level, resourceUserId); - if (!authCheck.allowed) { - return authContext.isAuthenticated - ? json(403, { message: authCheck.reason || 'Forbidden' }) - : json(401, { message: 'Authentication required' }); - } -} - -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /expenditures[/{proxy+}]; strip the - // mount prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/expenditures(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // GET /expenditures - if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated) { - return json(401, { message: 'Authentication required' }); - } - - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - const projectIdStr = queryParams.projectId as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - if (projectIdStr !== undefined) { - if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); - - return json(200, { - data: expenditures, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); - - return json(200, { data: expenditures }); - } - - // POST /expenditures - if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { - // Authenticate the request - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const { user } = authContext; - - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // Validate input - const validationResult = ExpenditureValidationUtils.validateExpenditureInput(body); - if (validationResult instanceof Error) { - return json(400, { message: validationResult.message }); - } - - const { projectID, amount, category, description, status, receiptUrl, spentOn } = validationResult; - - // Authorize: must be global admin, or Director/Admin on this project - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', projectID) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership || !['Director', 'Admin'].includes(membership.role)) { - return json(403, { message: 'Unable to create expenditure for this project' }); - } - } - - // Check if project exists - const project = await db - .selectFrom('branch.projects') - .where('project_id', '=', projectID) - .selectAll() - .executeTakeFirst(); - - if (!project) { - return json(404, { message: 'Project not found' }); - } - - // Insert expenditure with authenticated user as entered_by - try { - await db - .insertInto('branch.expenditures') - .values({ - project_id: projectID, - entered_by: user.userId!, - amount, - category: category ?? null, - description: description ?? null, - status, - receipt_url: receiptUrl ?? null, - spent_on: spentOn ? new Date(spentOn) : new Date(), - }) - .executeTakeFirst(); - } catch (err) { - console.error('Database insert error:', err); - return json(500, { message: 'Failed to create expenditure' }); - } - - return json(201, { - ok: true, - route: 'POST /expenditures', - body: { - projectID, - enteredBy: user.userId!, - amount, - category: category ?? null, - description: description ?? null, - status, - receiptUrl: receiptUrl ?? null, - spentOn: spentOn ?? new Date().toISOString().split('T')[0], - }, - }); - } - - // GET /expenditures/upload-url — presigned PUT for a receipt PDF. - // Must be matched before GET /expenditures/{id}, which also matches one segment. - if ((normalizedPath === '/expenditures/upload-url' || normalizedPath === '/upload-url') && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - const { user } = authContext; - - const queryParams = event.queryStringParameters || {}; - const { fileName, projectId: projectIdStr } = queryParams; - - if (!fileName || typeof fileName !== 'string') { - return json(400, { message: 'fileName is required' }); - } - if (fileName.split('.').pop()?.toLowerCase() !== 'pdf') { - return json(400, { message: 'Only PDF receipts are supported' }); - } - if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - const projectId = parseInt(projectIdStr, 10); - - // Same authorization as POST /expenditures: you may only attach a receipt - // to a project you are allowed to file an expenditure against. - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', projectId) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership || !['Director', 'Admin'].includes(membership.role)) { - return json(403, { message: 'Unable to upload a receipt for this project' }); - } - } - - const key = `receipts/${projectId}/${Date.now()}-${fileName}`; - const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - ContentType: RECEIPT_CONTENT_TYPE, - }), { expiresIn: 3600 }); - - return json(200, { - uploadUrl, - objectUrl: `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`, - }); - } - - // GET /expenditures/{id}/receipt — presigned GET so the receipt can be read - // without the bucket being public. - const receiptSegments = normalizedPath.split('/').filter(Boolean); - if (receiptSegments.length >= 2 && receiptSegments[receiptSegments.length - 1] === 'receipt' && method === 'GET') { - const id = receiptSegments[receiptSegments.length - 2]; - if (!/^\d+$/.test(id) || parseInt(id, 10) < 1) { - return json(400, { message: 'id must be a positive integer' }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - const { user } = authContext; - - const expenditure = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!expenditure) return json(404, { message: 'Expenditure not found' }); - - // Mirrors GET /expenditures/{id}: admin, or any membership on the project. - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', expenditure.project_id) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'Unable to view this receipt' }); - } - } - - if (!expenditure.receipt_url) { - return json(404, { message: 'Expenditure has no receipt' }); - } - - const key = receiptKeyFromUrl(expenditure.receipt_url); - if (!key) { - return json(422, { message: 'Receipt is not stored in the receipts bucket' }); - } - - const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({ - Bucket: BUCKET, - Key: key, - }), { expiresIn: 300 }); - - return json(200, { - downloadUrl, - fileName: key.split('/').pop(), - }); - } - - // GET /expenditures/{id} - if (/^\/[^\/]+$/.test(normalizedPath) && method === 'GET') { - const id = normalizedPath.split('/')[1]; - if (!id) return json(400, { message: 'id is required' }); - - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const { user } = authContext; - - const expenditure = await db.selectFrom("branch.expenditures").where("expenditure_id", "=", Number(id)).selectAll().executeTakeFirst(); - if (!expenditure) return json(404, { message: 'Expenditure not found' }); - - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', expenditure.project_id) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'Unable to view this expenditure' }); - } - } - - // "Submitted By" in the review modal needs a name, not an id. - const submitter = expenditure.entered_by - ? await db - .selectFrom('branch.users') - .where('user_id', '=', expenditure.entered_by) - .select(['name']) - .executeTakeFirst() - : undefined; - - const project = await db - .selectFrom('branch.projects') - .where('project_id', '=', expenditure.project_id) - .select(['name']) - .executeTakeFirst(); - - return json(200, { - ok: true, - route: 'GET /expenditures/{id}', - pathParams: { id }, - body: { - expenditureId: expenditure.expenditure_id, - projectId: expenditure.project_id, - projectName: project?.name ?? null, - enteredBy: expenditure.entered_by, - submittedByName: submitter?.name ?? null, - amount: expenditure.amount, - category: expenditure.category, - description: expenditure.description, - status: expenditure.status, - adminNotes: expenditure.admin_notes, - receiptUrl: expenditure.receipt_url, - spent_on: expenditure.spent_on, - createdAt: expenditure.created_at, - } - }); - } - - // DELETE /expenditures/{id} - if (/^\/[^\/]+$/.test(normalizedPath) && method === 'DELETE') { - const id = normalizedPath.split('/')[1]; - if (!id) return json(400, { message: 'id is required' }); - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - const { user } = authContext; - - const expenditure = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!expenditure) { - return json(404, { message: 'Expenditure not found' }); - } - - // (mirrors POST endpoint) Authorize: must be global admin, or Director/Admin on this expenditure's project - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', expenditure.project_id) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership || !['Director', 'Admin'].includes(membership.role)) { - return json(403, { message: 'Unable to delete this expenditure' }); - } - } - - const deleted = await db.deleteFrom('branch.expenditures').where('expenditure_id', '=', Number(id)).execute(); - - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Expenditure not found' }); - } - - return json(200, { ok: true, route: 'DELETE /expenditures/{id}', pathParams: { id } }); - } - - // PATCH /expenditures/{id}/status — approve/decline (admin only) - // (dev server strips the /expenditures prefix, so match the trailing /{id}/status) - const statusSegments = normalizedPath.split('/').filter(Boolean); - if ((statusSegments.length >= 2 && statusSegments[statusSegments.length - 1] === 'status') && method === 'PATCH') { - const authContext = await authenticateRequest(event); - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const id = statusSegments[statusSegments.length - 2]; - if (!/^\d+$/.test(id) || parseInt(id, 10) < 1) { - return json(400, { message: 'id must be a positive integer' }); - } - - const body = event.body ? JSON.parse(event.body) as Record : {}; - - const statusResult = ExpenditureValidationUtils.validateApprovalStatus(body.status); - if (statusResult instanceof Error) { - return json(400, { message: statusResult.message }); - } - - const adminNotesResult = ExpenditureValidationUtils.validateAdminNotes(body.adminNotes); - if (adminNotesResult instanceof Error) { - return json(400, { message: adminNotesResult.message }); - } - - // make sure expenditure exists - const expenditure = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!expenditure) { - return json(404, { message: 'Expenditure not found' }); - } - - // update - await db - .updateTable('branch.expenditures') - .set( - adminNotesResult === undefined - ? { status: statusResult } - : { status: statusResult, admin_notes: adminNotesResult }, - ) - .where('expenditure_id', '=', Number(id)) - .execute(); - - // get updated expenditure - const updated = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - return json(200, { - ok: true, - route: 'PATCH /expenditures/{id}/status', - pathParams: { id }, - body: { - expenditureId: updated!.expenditure_id, - status: updated!.status, - adminNotes: updated!.admin_notes, - }, - }); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} \ No newline at end of file +export const handler = (event: any) => dispatch(event, { prefix: 'expenditures', routes }); diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index 5001a1ca..9e1618cc 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "kysely": "^0.28.8", @@ -48,6 +49,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -878,6 +895,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index e9f9896f..d24c0a25 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -29,6 +29,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/expenditures/routes.ts b/apps/backend/lambdas/expenditures/routes.ts new file mode 100644 index 00000000..86882e05 --- /dev/null +++ b/apps/backend/lambdas/expenditures/routes.ts @@ -0,0 +1,23 @@ +import type { Route } from '@branch/lambda-http'; +import { + getExpenditures, + createExpenditure, + getUploadUrl, + getReceipt, + getExpenditureById, + deleteExpenditure, + patchExpenditureStatus, +} from './controllers/expenditures'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'GET', pattern: '/expenditures', handler: getExpenditures }, + { method: 'POST', pattern: '/expenditures', handler: createExpenditure }, + // /expenditures/upload-url must precede /expenditures/:id — both are one segment. + { method: 'GET', pattern: '/expenditures/upload-url', handler: getUploadUrl }, + { method: 'GET', pattern: '/expenditures/:id/receipt', handler: getReceipt }, + { method: 'GET', pattern: '/expenditures/:id', handler: getExpenditureById }, + { method: 'DELETE', pattern: '/expenditures/:id', handler: deleteExpenditure }, + { method: 'PATCH', pattern: '/expenditures/:id/status', handler: patchExpenditureStatus }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/expenditures/services/expenditures.ts b/apps/backend/lambdas/expenditures/services/expenditures.ts new file mode 100644 index 00000000..1f874e70 --- /dev/null +++ b/apps/backend/lambdas/expenditures/services/expenditures.ts @@ -0,0 +1,105 @@ +import { Insertable } from 'kysely'; +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { DB } from '@branch/types'; +import db from '../db'; +import type { ExpenditureStatus } from '../validation-utils'; + +const REGION = process.env.AWS_REGION ?? 'us-east-2'; +const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; +const s3 = new S3Client({ region: REGION }); + +// Receipts are PDFs only, matching the dropzone in AddExpenseModal. +export const RECEIPT_CONTENT_TYPE = 'application/pdf'; + +// Receipts live in the same bucket as reports, under their own prefix. +export function receiptKeyFromUrl(objectUrl: string): string | null { + const match = objectUrl.match(/^https:\/\/[^/]+\/(receipts\/.+)$/); + return match ? decodeURIComponent(match[1]) : null; +} + +export async function countExpenditures(projectId: number | null): Promise { + const totalCount = projectId !== null + ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst() + : await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); + + return Number(totalCount?.count || 0); +} + +export async function queryExpenditures(projectId: number | null, page?: { limit: number; offset: number }) { + if (page) { + return projectId !== null + ? db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(page.limit).offset(page.offset).execute() + : db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(page.limit).offset(page.offset).execute(); + } + + return projectId !== null + ? db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute() + : db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); +} + +export async function findMembership(projectId: number, userId: number) { + return db + .selectFrom('branch.project_memberships') + .where('project_id', '=', projectId) + .where('user_id', '=', userId) + .select('role') + .executeTakeFirst(); +} + +export async function findProjectById(projectId: number) { + return db.selectFrom('branch.projects').where('project_id', '=', projectId).selectAll().executeTakeFirst(); +} + +export async function findProjectName(projectId: number): Promise { + const row = await db.selectFrom('branch.projects').where('project_id', '=', projectId).select(['name']).executeTakeFirst(); + return row?.name; +} + +export async function findUserName(userId: number): Promise { + const row = await db.selectFrom('branch.users').where('user_id', '=', userId).select(['name']).executeTakeFirst(); + return row?.name; +} + +export async function insertExpenditure(values: Insertable): Promise { + await db.insertInto('branch.expenditures').values(values).executeTakeFirst(); +} + +export async function findExpenditureById(id: number) { + return db.selectFrom('branch.expenditures').where('expenditure_id', '=', id).selectAll().executeTakeFirst(); +} + +export async function deleteExpenditureById(id: number): Promise { + const deleted = await db.deleteFrom('branch.expenditures').where('expenditure_id', '=', id).execute(); + return deleted[0]?.numDeletedRows ?? 0n; +} + +export async function updateExpenditureStatus( + id: number, + status: ExpenditureStatus, + adminNotes: string | undefined, +): Promise { + await db + .updateTable('branch.expenditures') + .set(adminNotes === undefined ? { status } : { status, admin_notes: adminNotes }) + .where('expenditure_id', '=', id) + .execute(); +} + +export async function presignUploadUrl(projectId: number, fileName: string): Promise<{ uploadUrl: string; objectUrl: string }> { + const key = `receipts/${projectId}/${Date.now()}-${fileName}`; + const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + ContentType: RECEIPT_CONTENT_TYPE, + }), { expiresIn: 3600 }); + + return { + uploadUrl, + objectUrl: `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`, + }; +} + +export async function presignReceiptDownload(key: string): Promise { + return getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), { expiresIn: 300 }); +} diff --git a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts index 25ea7f19..fbbc322f 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts @@ -1078,6 +1078,19 @@ describe('GET /expenditures/upload-url unit tests', () => { expect(json.objectUrl).toContain('receipt.pdf'); }); + test('route precedence: /expenditures/upload-url reaches the upload-url controller, not /expenditures/:id', async () => { + // If route order regressed, this would hit the :id controller with id="upload-url" + // and 400 on the digit check instead of presigning. + const res = await handler(uploadUrlEvent({ fileName: 'receipt.pdf', projectId: '1' })); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json).toHaveProperty('uploadUrl'); + expect(json).toHaveProperty('objectUrl'); + expect(json).not.toHaveProperty('route'); + expect(mockDb.selectFrom).not.toHaveBeenCalledWith('branch.expenditures'); + }); + test('400: non-PDF is rejected', async () => { const res = await handler(uploadUrlEvent({ fileName: 'receipt.png', projectId: '1' })); diff --git a/apps/backend/lambdas/expenditures/tsconfig.json b/apps/backend/lambdas/expenditures/tsconfig.json index d35b2baa..c63669f7 100644 --- a/apps/backend/lambdas/expenditures/tsconfig.json +++ b/apps/backend/lambdas/expenditures/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts", "services/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } From 90574dd6a1bcf59bb621f72bf060a77302667d6c Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 13:58:23 -0400 Subject: [PATCH 06/20] refactor(auth): convert if-chain router to declarative route table Adopts @branch/lambda-http: handler.ts is now a one-line dispatch() call over routes.ts's ordered Route[] table, keeping the CLI ROUTES-START/END markers around the table entries. Moved, not changed: - controllers/auth.ts: login, respond-challenge, refresh, me, logout - controllers/register.ts: register, verify-email, resend-code - controllers/password.ts: forgot-password, reset-password - services/cognito.ts: cognitoClient, USER_POOL_CLIENT_ID/ID, CHALLENGE_SPECS, authResultResponse, challengeResponse, mapCognitoAuthError, validatePassword (byte-identical rules) Local json()/parseBody() deleted in favor of @branch/lambda-http's versions (identical implementations). Route order matches the original if-chain exactly. No status codes, response bodies, message strings, Cognito calls/params, password rules or auth gates changed. Added @branch/lambda-http as a dependency and regenerated package-lock.json; extended tsconfig include for controllers/services. --- apps/backend/lambdas/auth/controllers/auth.ts | 257 ++++++ .../lambdas/auth/controllers/password.ts | 82 ++ .../lambdas/auth/controllers/register.ts | 284 ++++++ apps/backend/lambdas/auth/handler.ts | 848 +----------------- apps/backend/lambdas/auth/package-lock.json | 21 + apps/backend/lambdas/auth/package.json | 1 + apps/backend/lambdas/auth/routes.ts | 27 + apps/backend/lambdas/auth/services/cognito.ts | 162 ++++ apps/backend/lambdas/auth/tsconfig.json | 2 +- 9 files changed, 838 insertions(+), 846 deletions(-) create mode 100644 apps/backend/lambdas/auth/controllers/auth.ts create mode 100644 apps/backend/lambdas/auth/controllers/password.ts create mode 100644 apps/backend/lambdas/auth/controllers/register.ts create mode 100644 apps/backend/lambdas/auth/routes.ts create mode 100644 apps/backend/lambdas/auth/services/cognito.ts diff --git a/apps/backend/lambdas/auth/controllers/auth.ts b/apps/backend/lambdas/auth/controllers/auth.ts new file mode 100644 index 00000000..1b8d4144 --- /dev/null +++ b/apps/backend/lambdas/auth/controllers/auth.ts @@ -0,0 +1,257 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + InitiateAuthCommand, + InitiateAuthCommandInput, + RespondToAuthChallengeCommand, + GlobalSignOutCommand, + GlobalSignOutCommandInput, + ChallengeNameType, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json, parseBody } from '@branch/lambda-http'; +import { authenticateRequest } from '../auth'; +import db from '../db'; +import { + cognitoClient, + USER_POOL_CLIENT_ID, + CHALLENGE_SPECS, + authResultResponse, + challengeResponse, + mapCognitoAuthError, + validatePassword, +} from '../services/cognito'; + +/** + * POST /login + * + * Uses USER_PASSWORD_AUTH rather than SRP. The browser already posts the + * plaintext password to this endpoint over TLS, so server-side SRP adds no + * confidentiality -- and unlike the SRP library, the SDK hands back the + * challenge Session as an opaque string that survives across invocations, + * which is what makes a stateless POST /respond-challenge possible. + * + * Every branch returns. An unrecognised ChallengeName is passed to the client + * as a value rather than silently never resolving a promise, which is how the + * previous callback-based implementation hung until the 30s lambda timeout. + */ +export async function handleLogin(event: any): Promise { + const body = parseBody(event); + if (!body) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { email, password } = body; + if (!email || !password) { + return json(400, { message: 'email and password are required' }); + } + + // Registration stores email.toLowerCase(), so sign-in must match. + const username = String(email).toLowerCase(); + + const params: InitiateAuthCommandInput = { + AuthFlow: 'USER_PASSWORD_AUTH', + ClientId: USER_POOL_CLIENT_ID, + // No SECRET_HASH: the app client is created with generate_secret = false. + AuthParameters: { USERNAME: username, PASSWORD: String(password) }, + }; + + try { + const response = await cognitoClient.send(new InitiateAuthCommand(params)); + + if (response.AuthenticationResult) { + return authResultResponse(response.AuthenticationResult); + } + + if (response.ChallengeName) { + // MFA_SETUP cannot be answered by RespondToAuthChallenge alone -- it needs + // AssociateSoftwareToken/VerifySoftwareToken enrollment, which is not + // built yet. Return the Session anyway so a future enrollment endpoint can + // resume without forcing a fresh sign-in. + if (response.ChallengeName === 'MFA_SETUP') { + return json(403, { + ChallengeName: response.ChallengeName, + Session: response.Session, + message: 'MFA enrollment is required but not yet supported', + }); + } + return challengeResponse(response); + } + + return json(500, { message: 'Unexpected response from authentication service' }); + } catch (error: any) { + return mapCognitoAuthError(error, 'login'); + } +} + +/** + * POST /respond-challenge + * + * Answers whatever POST /login returned, using the opaque Session string. + * Responses chain: a challenge may be followed by another challenge (the usual + * NEW_PASSWORD_REQUIRED then TOTP-enrollment path), so the caller must branch on + * the response the same way it branches on /login. + */ +export async function handleRespondChallenge(event: any): Promise { + const body = parseBody(event); + if (!body) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { challengeName, session, email } = body; + if (!challengeName || !session || !email) { + return json(400, { + message: 'challengeName, session, and email are required', + }); + } + + const spec = CHALLENGE_SPECS[String(challengeName)]; + if (!spec) { + return json(400, { + message: `Unsupported challenge: ${challengeName}`, + supported: Object.keys(CHALLENGE_SPECS), + }); + } + + for (const field of spec.required) { + if (!body[field]) { + return json(400, { message: `${field} is required for ${challengeName}` }); + } + } + + if (challengeName === 'NEW_PASSWORD_REQUIRED') { + const passwordError = validatePassword(body.newPassword); + if (passwordError) { + return json(400, { message: passwordError }); + } + } + + try { + const response = await cognitoClient.send( + new RespondToAuthChallengeCommand({ + ClientId: USER_POOL_CLIENT_ID, + ChallengeName: challengeName as ChallengeNameType, + Session: String(session), + ChallengeResponses: spec.build(body, String(email).toLowerCase()), + }), + ); + + if (response.AuthenticationResult) { + return authResultResponse(response.AuthenticationResult); + } + if (response.ChallengeName) { + return challengeResponse(response); + } + return json(500, { message: 'Unexpected response from authentication service' }); + } catch (error: any) { + return mapCognitoAuthError(error, 'challenge'); + } +} + +/** + * POST /refresh + * + * Exchanges a refresh token for a new access and ID token. Cognito does NOT + * return a new refresh token here (no rotation is configured), so the client + * must keep the one it already stored until it expires. + */ +export async function handleRefresh(event: any): Promise { + const body = parseBody(event); + if (!body) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { refreshToken } = body; + if (!refreshToken) { + return json(400, { message: 'refreshToken is required' }); + } + + try { + const response = await cognitoClient.send( + new InitiateAuthCommand({ + AuthFlow: 'REFRESH_TOKEN_AUTH', + ClientId: USER_POOL_CLIENT_ID, + AuthParameters: { REFRESH_TOKEN: String(refreshToken) }, + }), + ); + + if (!response.AuthenticationResult) { + return json(401, { message: 'Refresh token is invalid or expired' }); + } + return authResultResponse(response.AuthenticationResult); + } catch (error: any) { + return mapCognitoAuthError(error, 'refresh'); + } +} + +/** + * GET /me -- the canonical session bootstrap endpoint. + * + * Everything is read from Postgres rather than the token, for two reasons: a + * Cognito *access* token carries sub/scope/client_id/token_use but neither email + * nor name, and is_admin exists only in branch.users -- there is no + * pre-token-generation trigger, so it is not a JWT claim. This endpoint is the + * only way the frontend can learn whether the caller is an admin. + */ +export async function handleMe(event: any): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const me = await db + .selectFrom('branch.users') + .where('cognito_sub', '=', authContext.user.cognitoSub) + .select(['user_id', 'cognito_sub', 'email', 'name', 'is_admin', 'profile_image']) + .executeTakeFirst(); + + // Defensive: authenticateRequest already rejects a token whose sub has no row, + // so this is unreachable today. Kept so a future refactor cannot turn a + // missing row into a 500. 401 rather than 404 -- from the caller's point of + // view the session is unusable, and it keeps /me from being a user-existence + // oracle. + if (!me) { + return json(401, { message: 'Authentication required' }); + } + + return json(200, { + userId: me.user_id, + cognitoSub: me.cognito_sub, + email: me.email, + name: me.name, + isAdmin: me.is_admin === true, + profileImage: me.profile_image, + }); +} + +/** POST /logout -- revokes every token issued to the caller's Cognito session. */ +export async function handleLogout(event: any): Promise { + const authHeader = event.headers?.authorization || event.headers?.Authorization; + if (!authHeader) { + return json(401, { message: 'Authorization header is required' }); + } + + // Extract token (remove "Bearer " prefix if present) + const accessToken = authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : authHeader; + + if (!accessToken) { + return json(401, { message: 'Access token is required' }); + } + + const params: GlobalSignOutCommandInput = { + AccessToken: accessToken, + }; + + try { + await cognitoClient.send(new GlobalSignOutCommand(params)); + return json(200, { message: 'Logged out successfully' }); + } catch (error: any) { + console.error('Logout error:', error); + + if (error.name === 'NotAuthorizedException') { + return json(401, { message: 'Invalid or expired token' }); + } + + return json(500, { message: 'Failed to logout' }); + } +} diff --git a/apps/backend/lambdas/auth/controllers/password.ts b/apps/backend/lambdas/auth/controllers/password.ts new file mode 100644 index 00000000..b4fb4ca7 --- /dev/null +++ b/apps/backend/lambdas/auth/controllers/password.ts @@ -0,0 +1,82 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + ForgotPasswordCommand, + ForgotPasswordCommandInput, + ConfirmForgotPasswordCommand, + ConfirmForgotPasswordCommandInput, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json } from '@branch/lambda-http'; +import { cognitoClient, USER_POOL_CLIENT_ID } from '../services/cognito'; + +export async function handleForgotPassword(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email } = body; + if (!email) { + return json(400, { message: 'email is required' }); + } + + const params: ForgotPasswordCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: (email as string).toLowerCase(), + }; + + try { + const response = await cognitoClient.send(new ForgotPasswordCommand(params)); + return json(200, { + message: 'Password reset code sent', + deliveryMedium: response.CodeDeliveryDetails?.DeliveryMedium, + destination: response.CodeDeliveryDetails?.Destination, + }); + } catch (error: any) { + console.error('Forgot password error:', error); + if (error.name === 'UserNotFoundException') { + // Don't reveal whether the user exists + return json(200, { message: 'If an account with that email exists, a reset code has been sent' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many requests, please try again later' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: 'Cannot reset password for unverified email. Please verify your email first.' }); + } + return json(500, { message: 'Failed to initiate password reset' }); + } +} + +export async function handleResetPassword(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email, code, newPassword } = body; + if (!email || !code || !newPassword) { + return json(400, { message: 'email, code, and newPassword are required' }); + } + + const params: ConfirmForgotPasswordCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: (email as string).toLowerCase(), + ConfirmationCode: code as string, + Password: newPassword as string, + }; + + try { + await cognitoClient.send(new ConfirmForgotPasswordCommand(params)); + return json(200, { message: 'Password reset successfully' }); + } catch (error: any) { + console.error('Reset password error:', error); + if (error.name === 'CodeMismatchException') { + return json(400, { message: 'Invalid verification code' }); + } + if (error.name === 'ExpiredCodeException') { + return json(400, { message: 'Verification code has expired, please request a new one' }); + } + if (error.name === 'InvalidPasswordException') { + return json(400, { message: 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)' }); + } + if (error.name === 'UserNotFoundException') { + return json(400, { message: 'Invalid email or code' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + return json(500, { message: 'Failed to reset password' }); + } +} diff --git a/apps/backend/lambdas/auth/controllers/register.ts b/apps/backend/lambdas/auth/controllers/register.ts new file mode 100644 index 00000000..c650ca95 --- /dev/null +++ b/apps/backend/lambdas/auth/controllers/register.ts @@ -0,0 +1,284 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + SignUpCommand, + SignUpCommandInput, + AdminDeleteUserCommand, + AdminGetUserCommand, + ConfirmSignUpCommand, + ConfirmSignUpCommandInput, + ResendConfirmationCodeCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json } from '@branch/lambda-http'; +import db from '../db'; +import { cognitoClient, USER_POOL_CLIENT_ID, USER_POOL_ID, validatePassword } from '../services/cognito'; + +export async function handleRegister(event: any): Promise { + try { + // Parse request body + const body = event.body ? JSON.parse(event.body) : {}; + const { email, password, name } = body; + + // Validate required fields + if (!email || !password || !name) { + return json(400, { + message: 'Missing required fields', + required: ['email', 'password', 'name'], + }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return json(400, { message: 'Invalid email format' }); + } + + // Validate password requirements + const passwordError = validatePassword(password); + if (passwordError) { + return json(400, { message: passwordError }); + } + + // Validate name + if (name.trim().length < 2) { + return json(400, { message: 'Name must be at least 2 characters long' }); + } + + // A branch.users row with cognito_sub IS NULL is a PENDING INVITATION, not a + // conflict. Two paths create them: the db/seed.sql rows and admin + // POST /users. Before claim-on-register both were permanently unable to sign + // in -- registration 409'd on the email, and lambda-auth's authenticateRequest + // can never match a NULL cognito_sub. + const existingUser = await db + .selectFrom('branch.users') + .where('email', '=', email.toLowerCase()) + .selectAll() + .executeTakeFirst(); + + if (existingUser && existingUser.cognito_sub) { + return json(409, { message: 'User with this email already exists' }); + } + + // REGISTRATION IS INVITATION-ONLY. This endpoint is public and + // unauthenticated, so without this gate anyone could create a working + // account for themselves. An account is only meaningful once a branch.users + // row exists -- authenticateRequest rejects any Cognito identity whose sub + // has no row -- so refusing to create that row here is the control. + // + // The invitation must be created first by an admin via the ADMIN-gated + // POST /users, which inserts a row with a NULL cognito_sub. + // + // 403 rather than 404: this endpoint must not become an oracle for which + // email addresses have been invited, so the response is deliberately the + // same whether or not the address is known. + if (!existingUser) { + return json(403, { + message: + 'Registration is by invitation only. Ask an administrator to create your account.', + code: 'INVITATION_REQUIRED', + }); + } + + const claimingUserId: number = existingUser.user_id; + + // Prepare Cognito SignUp parameters + const signUpParams: SignUpCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: email.toLowerCase(), + Password: password, + UserAttributes: [ + { + Name: 'email', + Value: email.toLowerCase(), + }, + { + Name: 'name', + Value: name.trim(), + }, + ], + }; + + // Register user in Cognito + let cognitoUserSub: string; + try { + const command = new SignUpCommand(signUpParams); + const response = await cognitoClient.send(command); + cognitoUserSub = response.UserSub!; + } catch (error: any) { + console.error('Cognito registration error:', error); + + // Handle specific Cognito errors + if (error.name === 'UsernameExistsException') { + // The Cognito user exists but this DB row is an unclaimed invitation, so + // SignUp can never hand us a sub. Happens routinely in local dev: `make + // down-v` wipes Postgres while the shared dev pool keeps the user. Link + // the existing Cognito identity instead of dead-ending on a 409. + { + try { + // AdminGetUser is SigV4-signed and needs cognito-idp:AdminGetUser + // (granted in infrastructure/aws/lambda.tf). With no AWS credentials + // locally this throws and we fall through to the 409. + const cognitoUser = await cognitoClient.send( + new AdminGetUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email.toLowerCase(), + }), + ); + const sub = cognitoUser.UserAttributes?.find((a) => a.Name === 'sub')?.Value; + if (sub && cognitoUser.UserStatus === 'CONFIRMED') { + const linkResult = await db + .updateTable('branch.users') + .set({ cognito_sub: sub }) + .where('user_id', '=', claimingUserId) + .where('cognito_sub', 'is', null) + .executeTakeFirst(); + // A concurrent claim already took this row; do not delete the + // pre-existing Cognito user, it may back a working account. + if (linkResult.numUpdatedRows > 0n) { + return json(200, { + message: 'Existing account linked', + claimed: true, + email: email.toLowerCase(), + }); + } + } + } catch (linkError) { + console.warn('Could not auto-link existing Cognito user:', linkError); + } + } + return json(409, { + message: 'User with this email already exists', + code: 'COGNITO_USER_EXISTS', + }); + } + if (error.name === 'InvalidPasswordException') { + return json(400, { message: 'Password does not meet requirements' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: error.message || 'Invalid parameters provided' }); + } + + return json(500, { message: 'Failed to register user in authentication service' }); + } + + const rollbackCognitoUser = async () => { + try { + await cognitoClient.send( + new AdminDeleteUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email.toLowerCase(), + }) + ); + console.log('Rolled back Cognito user after database failure'); + } catch (rollbackError) { + console.error('Failed to rollback Cognito user:', rollbackError); + } + }; + + // Create user in database, or claim the pending invitation + try { + // Claim the invitation. is_admin is deliberately NOT touched: it was set + // by whoever created the invitation (a seed, or an admin via POST /users) + // and must never be settable from a public, unauthenticated endpoint. + // There is no insert path here -- registration cannot mint a new row, only + // claim one an admin already approved. The cognito_sub IS NULL predicate + // makes a concurrent claim a no-op rather than an overwrite; + // UNIQUE(cognito_sub) is the backstop. + const claimResult = await db + .updateTable('branch.users') + .set({ cognito_sub: cognitoUserSub, name: name.trim() }) + .where('user_id', '=', claimingUserId) + .where('cognito_sub', 'is', null) + .executeTakeFirst(); + + // No-op claim: the Cognito sub we just created would reference no row, so + // every later login would fail. Undo the Cognito user instead. + if (claimResult.numUpdatedRows === 0n) { + console.error('Invitation already claimed for user_id:', claimingUserId); + await rollbackCognitoUser(); + return json(409, { + message: 'User with this email already exists', + code: 'ALREADY_CLAIMED', + }); + } + } catch (dbError: any) { + console.error('Database insert error:', dbError); + + // Rollback: Delete user from Cognito if database insert fails + await rollbackCognitoUser(); + + return json(500, { message: 'Failed to create user account' }); + } + + return json(201, { + message: 'User registered successfully', + userId: cognitoUserSub, + email: email.toLowerCase(), + name: name.trim(), + emailVerificationRequired: true, + details: 'Please check your email for verification code', + claimed: true, + }); + } catch (error: any) { + console.error('Registration error:', error); + return json(500, { message: 'Internal server error during registration' }); + } +} + +export async function handleVerifyEmail(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email, code } = body; + if (!email || !code) { + return json(400, { message: 'email and code are required' }); + } + const params: ConfirmSignUpCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + ConfirmationCode: code as string, + }; + try { + await cognitoClient.send(new ConfirmSignUpCommand(params)); + } catch (error: any) { + console.error('Email verification error:', error); + if (error.name === 'NotAuthorizedException' && error.message?.includes('CONFIRMED')) { + return json(200, { message: `Email already verified for ${email}` }); + } + if (error.name === 'CodeMismatchException' || error.name === 'ExpiredCodeException') { + return json(400, { message: 'Invalid or expired verification code' }); + } + if (error.name === 'UserNotFoundException') { + return json(400, { message: 'Invalid code or email' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + return json(500, { message: 'Failed to verify email' }); + } + return json(200, { message: `Email verified successfully for ${email}` }); +} + +export async function handleResendCode(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email } = body; + if (!email) { + return json(400, { message: 'email is required' }); + } + try { + await cognitoClient.send(new ResendConfirmationCodeCommand({ + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + })); + return json(200, { message: `Verification code resent to ${email}` }); + } catch (error: any) { + if (error.name === 'UserNotFoundException') { + return json(404, { message: 'User not found' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: 'User is already confirmed' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + console.error('Resend code error:', error); + return json(500, { message: 'Failed to resend verification code' }); + } +} diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 507d7010..965afca6 100644 --- a/apps/backend/lambdas/auth/handler.ts +++ b/apps/backend/lambdas/auth/handler.ts @@ -1,846 +1,4 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { - CognitoIdentityProviderClient, - SignUpCommand, - SignUpCommandInput, - AdminDeleteUserCommand, - AdminGetUserCommand, - InitiateAuthCommand, - InitiateAuthCommandInput, - InitiateAuthCommandOutput, - RespondToAuthChallengeCommand, - RespondToAuthChallengeCommandOutput, - ConfirmSignUpCommand, - ConfirmSignUpCommandInput, - ResendConfirmationCodeCommand, +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; - GlobalSignOutCommand, - GlobalSignOutCommandInput, - ForgotPasswordCommand, - ForgotPasswordCommandInput, - ConfirmForgotPasswordCommand, - ConfirmForgotPasswordCommandInput, - AuthenticationResultType, - ChallengeNameType, -} from '@aws-sdk/client-cognito-identity-provider'; -import { authenticateRequest } from './auth'; -import db from './db'; - -// Initialize Cognito client (region defaults to us-east-2) -const cognitoClient = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION || 'us-east-2', -}); - -const USER_POOL_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; -const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; - -/** - * How to answer each Cognito auth challenge. - * - * Adding support for a new challenge type is adding a row here -- no routing, - * dispatch or flow changes. That is what makes enabling MFA on the user pool a - * configuration change rather than a code change: SOFTWARE_TOKEN_MFA, SMS_MFA, - * EMAIL_OTP and SELECT_MFA_TYPE are already wired and become reachable the - * moment mfa_configuration is turned on in infrastructure/aws/cognito.tf. - */ -interface ChallengeSpec { - /** Body fields that must be present for this challenge. */ - required: string[]; - /** Builds the Cognito ChallengeResponses map. */ - build: (body: Record, username: string) => Record; -} - -const CHALLENGE_SPECS: Record = { - NEW_PASSWORD_REQUIRED: { - required: ['newPassword'], - build: (body, username) => ({ - USERNAME: username, - NEW_PASSWORD: String(body.newPassword), - ...(body.name ? { 'userAttributes.name': String(body.name) } : {}), - }), - }, - SOFTWARE_TOKEN_MFA: { - required: ['code'], - build: (body, username) => ({ - USERNAME: username, - SOFTWARE_TOKEN_MFA_CODE: String(body.code), - }), - }, - SMS_MFA: { - required: ['code'], - build: (body, username) => ({ - USERNAME: username, - SMS_MFA_CODE: String(body.code), - }), - }, - EMAIL_OTP: { - required: ['code'], - build: (body, username) => ({ - USERNAME: username, - EMAIL_OTP_CODE: String(body.code), - }), - }, - SELECT_MFA_TYPE: { - required: ['mfaType'], - build: (body, username) => ({ - USERNAME: username, - ANSWER: String(body.mfaType), - }), - }, -}; - -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /auth[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/auth(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // POST /register - if (normalizedPath === '/register' && method === 'POST') { - return await handleRegister(event); - } - - - // POST /login - if (normalizedPath === '/login' && method === 'POST') { - return await handleLogin(event); - } - - // POST /respond-challenge - if (normalizedPath === '/respond-challenge' && method === 'POST') { - return await handleRespondChallenge(event); - } - - // POST /refresh - if (normalizedPath === '/refresh' && method === 'POST') { - return await handleRefresh(event); - } - - // GET /me - if (normalizedPath === '/me' && method === 'GET') { - return await handleMe(event); - } - - // POST /verify-email - if (normalizedPath === '/verify-email' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email, code } = body; - if (!email || !code) { - return json(400, { message: 'email and code are required' }); - } - const params: ConfirmSignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - ConfirmationCode: code as string, - }; - try { - await cognitoClient.send(new ConfirmSignUpCommand(params)); - } catch (error: any) { - console.error('Email verification error:', error); - if (error.name === 'NotAuthorizedException' && error.message?.includes('CONFIRMED')) { - return json(200, { message: `Email already verified for ${email}` }); - } - if (error.name === 'CodeMismatchException' || error.name === 'ExpiredCodeException') { - return json(400, { message: 'Invalid or expired verification code' }); - } - if (error.name === 'UserNotFoundException') { - return json(400, { message: 'Invalid code or email' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - return json(500, { message: 'Failed to verify email' }); - } - return json(200, { message: `Email verified successfully for ${email}` }); - } - - // POST /resend-code - if (normalizedPath === '/resend-code' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email } = body; - if (!email) { - return json(400, { message: 'email is required' }); - } - try { - await cognitoClient.send(new ResendConfirmationCodeCommand({ - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - })); - return json(200, { message: `Verification code resent to ${email}` }); - } catch (error: any) { - if (error.name === 'UserNotFoundException') { - return json(404, { message: 'User not found' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: 'User is already confirmed' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - console.error('Resend code error:', error); - return json(500, { message: 'Failed to resend verification code' }); - } - } - - // POST /logout - if (normalizedPath === '/logout' && method === 'POST') { - const authHeader = event.headers?.authorization || event.headers?.Authorization; - if (!authHeader) { - return json(401, { message: 'Authorization header is required' }); - } - - // Extract token (remove "Bearer " prefix if present) - const accessToken = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) - : authHeader; - - if (!accessToken) { - return json(401, { message: 'Access token is required' }); - } - - const params: GlobalSignOutCommandInput = { - AccessToken: accessToken, - }; - - try { - await cognitoClient.send(new GlobalSignOutCommand(params)); - return json(200, { message: 'Logged out successfully' }); - } catch (error: any) { - console.error('Logout error:', error); - - if (error.name === 'NotAuthorizedException') { - return json(401, { message: 'Invalid or expired token' }); - } - - return json(500, { message: 'Failed to logout' }); - } - } - - // POST /forgot-password - if (normalizedPath === '/forgot-password' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email } = body; - if (!email) { - return json(400, { message: 'email is required' }); - } - - const params: ForgotPasswordCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: (email as string).toLowerCase(), - }; - - try { - const response = await cognitoClient.send(new ForgotPasswordCommand(params)); - return json(200, { - message: 'Password reset code sent', - deliveryMedium: response.CodeDeliveryDetails?.DeliveryMedium, - destination: response.CodeDeliveryDetails?.Destination, - }); - } catch (error: any) { - console.error('Forgot password error:', error); - if (error.name === 'UserNotFoundException') { - // Don't reveal whether the user exists - return json(200, { message: 'If an account with that email exists, a reset code has been sent' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many requests, please try again later' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: 'Cannot reset password for unverified email. Please verify your email first.' }); - } - return json(500, { message: 'Failed to initiate password reset' }); - } - } - - // POST /reset-password - if (normalizedPath === '/reset-password' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email, code, newPassword } = body; - if (!email || !code || !newPassword) { - return json(400, { message: 'email, code, and newPassword are required' }); - } - - const params: ConfirmForgotPasswordCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: (email as string).toLowerCase(), - ConfirmationCode: code as string, - Password: newPassword as string, - }; - - try { - await cognitoClient.send(new ConfirmForgotPasswordCommand(params)); - return json(200, { message: 'Password reset successfully' }); - } catch (error: any) { - console.error('Reset password error:', error); - if (error.name === 'CodeMismatchException') { - return json(400, { message: 'Invalid verification code' }); - } - if (error.name === 'ExpiredCodeException') { - return json(400, { message: 'Verification code has expired, please request a new one' }); - } - if (error.name === 'InvalidPasswordException') { - return json(400, { message: 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)' }); - } - if (error.name === 'UserNotFoundException') { - return json(400, { message: 'Invalid email or code' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - return json(500, { message: 'Failed to reset password' }); - } - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -/** Parses a JSON body, returning null when it is not valid JSON. */ -function parseBody(event: any): Record | null { - try { - return event.body ? (JSON.parse(event.body) as Record) : {}; - } catch { - return null; - } -} - -/** - * Password rules, kept in one place so /register and /respond-challenge cannot - * drift. Returns an error message, or null when the password is acceptable. - * Mirrors the pool's password_policy in infrastructure/aws/cognito.tf. - */ -function validatePassword(password: unknown): string | null { - if (typeof password !== 'string') return 'Password must be a string'; - if (password.length < 8) return 'Password must be at least 8 characters long'; - if (!/[a-z]/.test(password)) return 'Password must contain at least one lowercase letter'; - if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter'; - if (!/[0-9]/.test(password)) return 'Password must contain at least one number'; - return null; -} - -/** 200 + the token set. Shape matches what the frontend AuthContext expects. */ -function authResultResponse(result: AuthenticationResultType): APIGatewayProxyResult { - return json(200, { - AccessToken: result.AccessToken, - IdToken: result.IdToken, - // Absent on REFRESH_TOKEN_AUTH: Cognito does not re-issue a refresh token. - RefreshToken: result.RefreshToken, - ExpiresIn: result.ExpiresIn, - TokenType: result.TokenType, - }); -} - -/** - * 200 + the challenge to answer next. The opaque Session is valid across - * processes, so the client can complete it with a separate request to - * POST /auth/respond-challenge. - */ -function challengeResponse( - response: InitiateAuthCommandOutput | RespondToAuthChallengeCommandOutput, -): APIGatewayProxyResult { - return json(200, { - ChallengeName: response.ChallengeName, - Session: response.Session, - ChallengeParameters: response.ChallengeParameters, - message: `Additional authentication step required: ${response.ChallengeName}`, - }); -} - -/** Single Cognito error -> HTTP mapping, shared by login, challenge and refresh. */ -function mapCognitoAuthError( - error: any, - stage: 'login' | 'challenge' | 'refresh', -): APIGatewayProxyResult { - console.error(`Cognito ${stage} error:`, error); - const code = error?.name; - - switch (code) { - case 'NotAuthorizedException': { - const message = - stage === 'refresh' - ? 'Refresh token is invalid or expired' - : stage === 'challenge' - ? 'Challenge session is invalid or expired, please sign in again' - : 'Invalid email or password'; - return json(401, { message, code }); - } - // prevent_user_existence_errors is ENABLED on the app client, so Cognito - // normally folds this into NotAuthorizedException. Handled for parity. - case 'UserNotFoundException': - return json(401, { message: 'Invalid email or password', code }); - case 'UserNotConfirmedException': - return json(403, { message: 'Email not verified', code }); - case 'PasswordResetRequiredException': - return json(403, { message: 'Password reset required', code }); - case 'CodeMismatchException': - return json(400, { message: 'Invalid verification code', code }); - case 'ExpiredCodeException': - return json(400, { message: 'Verification code has expired', code }); - case 'InvalidPasswordException': - return json(400, { - message: - 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)', - code, - }); - case 'InvalidParameterException': - return json(400, { message: error?.message || 'Invalid parameters provided', code }); - case 'TooManyRequestsException': - case 'LimitExceededException': - case 'TooManyFailedAttemptsException': - return json(429, { message: 'Too many attempts, please try again later', code }); - case 'ForbiddenException': - return json(403, { message: 'Request blocked', code }); - default: - return json(500, { message: 'Authentication failed', error: error?.message, code }); - } -} - -/** - * POST /login - * - * Uses USER_PASSWORD_AUTH rather than SRP. The browser already posts the - * plaintext password to this endpoint over TLS, so server-side SRP adds no - * confidentiality -- and unlike the SRP library, the SDK hands back the - * challenge Session as an opaque string that survives across invocations, - * which is what makes a stateless POST /respond-challenge possible. - * - * Every branch returns. An unrecognised ChallengeName is passed to the client - * as a value rather than silently never resolving a promise, which is how the - * previous callback-based implementation hung until the 30s lambda timeout. - */ -async function handleLogin(event: any): Promise { - const body = parseBody(event); - if (!body) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { email, password } = body; - if (!email || !password) { - return json(400, { message: 'email and password are required' }); - } - - // Registration stores email.toLowerCase(), so sign-in must match. - const username = String(email).toLowerCase(); - - const params: InitiateAuthCommandInput = { - AuthFlow: 'USER_PASSWORD_AUTH', - ClientId: USER_POOL_CLIENT_ID, - // No SECRET_HASH: the app client is created with generate_secret = false. - AuthParameters: { USERNAME: username, PASSWORD: String(password) }, - }; - - try { - const response = await cognitoClient.send(new InitiateAuthCommand(params)); - - if (response.AuthenticationResult) { - return authResultResponse(response.AuthenticationResult); - } - - if (response.ChallengeName) { - // MFA_SETUP cannot be answered by RespondToAuthChallenge alone -- it needs - // AssociateSoftwareToken/VerifySoftwareToken enrollment, which is not - // built yet. Return the Session anyway so a future enrollment endpoint can - // resume without forcing a fresh sign-in. - if (response.ChallengeName === 'MFA_SETUP') { - return json(403, { - ChallengeName: response.ChallengeName, - Session: response.Session, - message: 'MFA enrollment is required but not yet supported', - }); - } - return challengeResponse(response); - } - - return json(500, { message: 'Unexpected response from authentication service' }); - } catch (error: any) { - return mapCognitoAuthError(error, 'login'); - } -} - -/** - * POST /respond-challenge - * - * Answers whatever POST /login returned, using the opaque Session string. - * Responses chain: a challenge may be followed by another challenge (the usual - * NEW_PASSWORD_REQUIRED then TOTP-enrollment path), so the caller must branch on - * the response the same way it branches on /login. - */ -async function handleRespondChallenge(event: any): Promise { - const body = parseBody(event); - if (!body) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { challengeName, session, email } = body; - if (!challengeName || !session || !email) { - return json(400, { - message: 'challengeName, session, and email are required', - }); - } - - const spec = CHALLENGE_SPECS[String(challengeName)]; - if (!spec) { - return json(400, { - message: `Unsupported challenge: ${challengeName}`, - supported: Object.keys(CHALLENGE_SPECS), - }); - } - - for (const field of spec.required) { - if (!body[field]) { - return json(400, { message: `${field} is required for ${challengeName}` }); - } - } - - if (challengeName === 'NEW_PASSWORD_REQUIRED') { - const passwordError = validatePassword(body.newPassword); - if (passwordError) { - return json(400, { message: passwordError }); - } - } - - try { - const response = await cognitoClient.send( - new RespondToAuthChallengeCommand({ - ClientId: USER_POOL_CLIENT_ID, - ChallengeName: challengeName as ChallengeNameType, - Session: String(session), - ChallengeResponses: spec.build(body, String(email).toLowerCase()), - }), - ); - - if (response.AuthenticationResult) { - return authResultResponse(response.AuthenticationResult); - } - if (response.ChallengeName) { - return challengeResponse(response); - } - return json(500, { message: 'Unexpected response from authentication service' }); - } catch (error: any) { - return mapCognitoAuthError(error, 'challenge'); - } -} - -/** - * POST /refresh - * - * Exchanges a refresh token for a new access and ID token. Cognito does NOT - * return a new refresh token here (no rotation is configured), so the client - * must keep the one it already stored until it expires. - */ -async function handleRefresh(event: any): Promise { - const body = parseBody(event); - if (!body) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { refreshToken } = body; - if (!refreshToken) { - return json(400, { message: 'refreshToken is required' }); - } - - try { - const response = await cognitoClient.send( - new InitiateAuthCommand({ - AuthFlow: 'REFRESH_TOKEN_AUTH', - ClientId: USER_POOL_CLIENT_ID, - AuthParameters: { REFRESH_TOKEN: String(refreshToken) }, - }), - ); - - if (!response.AuthenticationResult) { - return json(401, { message: 'Refresh token is invalid or expired' }); - } - return authResultResponse(response.AuthenticationResult); - } catch (error: any) { - return mapCognitoAuthError(error, 'refresh'); - } -} - -/** - * GET /me -- the canonical session bootstrap endpoint. - * - * Everything is read from Postgres rather than the token, for two reasons: a - * Cognito *access* token carries sub/scope/client_id/token_use but neither email - * nor name, and is_admin exists only in branch.users -- there is no - * pre-token-generation trigger, so it is not a JWT claim. This endpoint is the - * only way the frontend can learn whether the caller is an admin. - */ -async function handleMe(event: any): Promise { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const me = await db - .selectFrom('branch.users') - .where('cognito_sub', '=', authContext.user.cognitoSub) - .select(['user_id', 'cognito_sub', 'email', 'name', 'is_admin', 'profile_image']) - .executeTakeFirst(); - - // Defensive: authenticateRequest already rejects a token whose sub has no row, - // so this is unreachable today. Kept so a future refactor cannot turn a - // missing row into a 500. 401 rather than 404 -- from the caller's point of - // view the session is unusable, and it keeps /me from being a user-existence - // oracle. - if (!me) { - return json(401, { message: 'Authentication required' }); - } - - return json(200, { - userId: me.user_id, - cognitoSub: me.cognito_sub, - email: me.email, - name: me.name, - isAdmin: me.is_admin === true, - profileImage: me.profile_image, - }); -} - -async function handleRegister(event: any): Promise { - try { - // Parse request body - const body = event.body ? JSON.parse(event.body) : {}; - const { email, password, name } = body; - - // Validate required fields - if (!email || !password || !name) { - return json(400, { - message: 'Missing required fields', - required: ['email', 'password', 'name'], - }); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return json(400, { message: 'Invalid email format' }); - } - - // Validate password requirements - const passwordError = validatePassword(password); - if (passwordError) { - return json(400, { message: passwordError }); - } - - // Validate name - if (name.trim().length < 2) { - return json(400, { message: 'Name must be at least 2 characters long' }); - } - - // A branch.users row with cognito_sub IS NULL is a PENDING INVITATION, not a - // conflict. Two paths create them: the db/seed.sql rows and admin - // POST /users. Before claim-on-register both were permanently unable to sign - // in -- registration 409'd on the email, and lambda-auth's authenticateRequest - // can never match a NULL cognito_sub. - const existingUser = await db - .selectFrom('branch.users') - .where('email', '=', email.toLowerCase()) - .selectAll() - .executeTakeFirst(); - - if (existingUser && existingUser.cognito_sub) { - return json(409, { message: 'User with this email already exists' }); - } - - // REGISTRATION IS INVITATION-ONLY. This endpoint is public and - // unauthenticated, so without this gate anyone could create a working - // account for themselves. An account is only meaningful once a branch.users - // row exists -- authenticateRequest rejects any Cognito identity whose sub - // has no row -- so refusing to create that row here is the control. - // - // The invitation must be created first by an admin via the ADMIN-gated - // POST /users, which inserts a row with a NULL cognito_sub. - // - // 403 rather than 404: this endpoint must not become an oracle for which - // email addresses have been invited, so the response is deliberately the - // same whether or not the address is known. - if (!existingUser) { - return json(403, { - message: - 'Registration is by invitation only. Ask an administrator to create your account.', - code: 'INVITATION_REQUIRED', - }); - } - - const claimingUserId: number = existingUser.user_id; - - // Prepare Cognito SignUp parameters - const signUpParams: SignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email.toLowerCase(), - Password: password, - UserAttributes: [ - { - Name: 'email', - Value: email.toLowerCase(), - }, - { - Name: 'name', - Value: name.trim(), - }, - ], - }; - - // Register user in Cognito - let cognitoUserSub: string; - try { - const command = new SignUpCommand(signUpParams); - const response = await cognitoClient.send(command); - cognitoUserSub = response.UserSub!; - } catch (error: any) { - console.error('Cognito registration error:', error); - - // Handle specific Cognito errors - if (error.name === 'UsernameExistsException') { - // The Cognito user exists but this DB row is an unclaimed invitation, so - // SignUp can never hand us a sub. Happens routinely in local dev: `make - // down-v` wipes Postgres while the shared dev pool keeps the user. Link - // the existing Cognito identity instead of dead-ending on a 409. - { - try { - // AdminGetUser is SigV4-signed and needs cognito-idp:AdminGetUser - // (granted in infrastructure/aws/lambda.tf). With no AWS credentials - // locally this throws and we fall through to the 409. - const cognitoUser = await cognitoClient.send( - new AdminGetUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email.toLowerCase(), - }), - ); - const sub = cognitoUser.UserAttributes?.find((a) => a.Name === 'sub')?.Value; - if (sub && cognitoUser.UserStatus === 'CONFIRMED') { - const linkResult = await db - .updateTable('branch.users') - .set({ cognito_sub: sub }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); - // A concurrent claim already took this row; do not delete the - // pre-existing Cognito user, it may back a working account. - if (linkResult.numUpdatedRows > 0n) { - return json(200, { - message: 'Existing account linked', - claimed: true, - email: email.toLowerCase(), - }); - } - } - } catch (linkError) { - console.warn('Could not auto-link existing Cognito user:', linkError); - } - } - return json(409, { - message: 'User with this email already exists', - code: 'COGNITO_USER_EXISTS', - }); - } - if (error.name === 'InvalidPasswordException') { - return json(400, { message: 'Password does not meet requirements' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: error.message || 'Invalid parameters provided' }); - } - - return json(500, { message: 'Failed to register user in authentication service' }); - } - - const rollbackCognitoUser = async () => { - try { - await cognitoClient.send( - new AdminDeleteUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email.toLowerCase(), - }) - ); - console.log('Rolled back Cognito user after database failure'); - } catch (rollbackError) { - console.error('Failed to rollback Cognito user:', rollbackError); - } - }; - - // Create user in database, or claim the pending invitation - try { - // Claim the invitation. is_admin is deliberately NOT touched: it was set - // by whoever created the invitation (a seed, or an admin via POST /users) - // and must never be settable from a public, unauthenticated endpoint. - // There is no insert path here -- registration cannot mint a new row, only - // claim one an admin already approved. The cognito_sub IS NULL predicate - // makes a concurrent claim a no-op rather than an overwrite; - // UNIQUE(cognito_sub) is the backstop. - const claimResult = await db - .updateTable('branch.users') - .set({ cognito_sub: cognitoUserSub, name: name.trim() }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); - - // No-op claim: the Cognito sub we just created would reference no row, so - // every later login would fail. Undo the Cognito user instead. - if (claimResult.numUpdatedRows === 0n) { - console.error('Invitation already claimed for user_id:', claimingUserId); - await rollbackCognitoUser(); - return json(409, { - message: 'User with this email already exists', - code: 'ALREADY_CLAIMED', - }); - } - } catch (dbError: any) { - console.error('Database insert error:', dbError); - - // Rollback: Delete user from Cognito if database insert fails - await rollbackCognitoUser(); - - return json(500, { message: 'Failed to create user account' }); - } - - return json(201, { - message: 'User registered successfully', - userId: cognitoUserSub, - email: email.toLowerCase(), - name: name.trim(), - emailVerificationRequired: true, - details: 'Please check your email for verification code', - claimed: true, - }); - } catch (error: any) { - console.error('Registration error:', error); - return json(500, { message: 'Internal server error during registration' }); - } -} - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +export const handler = (event: any) => dispatch(event, { prefix: 'auth', routes }); diff --git a/apps/backend/lambdas/auth/package-lock.json b/apps/backend/lambdas/auth/package-lock.json index 469fa6c3..a5253822 100644 --- a/apps/backend/lambdas/auth/package-lock.json +++ b/apps/backend/lambdas/auth/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", @@ -47,6 +48,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1217,6 +1234,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index 276a9c88..8aa1601c 100644 --- a/apps/backend/lambdas/auth/package.json +++ b/apps/backend/lambdas/auth/package.json @@ -27,6 +27,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", diff --git a/apps/backend/lambdas/auth/routes.ts b/apps/backend/lambdas/auth/routes.ts new file mode 100644 index 00000000..9a47604d --- /dev/null +++ b/apps/backend/lambdas/auth/routes.ts @@ -0,0 +1,27 @@ +import type { Route } from '@branch/lambda-http'; +import { handleRegister, handleVerifyEmail, handleResendCode } from './controllers/register'; +import { + handleLogin, + handleRespondChallenge, + handleRefresh, + handleMe, + handleLogout, +} from './controllers/auth'; +import { handleForgotPassword, handleResetPassword } from './controllers/password'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + // CLI-generated routes will be inserted here + + { method: 'POST', pattern: '/auth/register', handler: ({ event }) => handleRegister(event) }, + { method: 'POST', pattern: '/auth/login', handler: ({ event }) => handleLogin(event) }, + { method: 'POST', pattern: '/auth/respond-challenge', handler: ({ event }) => handleRespondChallenge(event) }, + { method: 'POST', pattern: '/auth/refresh', handler: ({ event }) => handleRefresh(event) }, + { method: 'GET', pattern: '/auth/me', handler: ({ event }) => handleMe(event) }, + { method: 'POST', pattern: '/auth/verify-email', handler: ({ event }) => handleVerifyEmail(event) }, + { method: 'POST', pattern: '/auth/resend-code', handler: ({ event }) => handleResendCode(event) }, + { method: 'POST', pattern: '/auth/logout', handler: ({ event }) => handleLogout(event) }, + { method: 'POST', pattern: '/auth/forgot-password', handler: ({ event }) => handleForgotPassword(event) }, + { method: 'POST', pattern: '/auth/reset-password', handler: ({ event }) => handleResetPassword(event) }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/auth/services/cognito.ts b/apps/backend/lambdas/auth/services/cognito.ts new file mode 100644 index 00000000..220d18db --- /dev/null +++ b/apps/backend/lambdas/auth/services/cognito.ts @@ -0,0 +1,162 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + CognitoIdentityProviderClient, + AuthenticationResultType, + InitiateAuthCommandOutput, + RespondToAuthChallengeCommandOutput, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json } from '@branch/lambda-http'; + +// Initialize Cognito client (region defaults to us-east-2) +export const cognitoClient = new CognitoIdentityProviderClient({ + region: process.env.AWS_REGION || 'us-east-2', +}); + +export const USER_POOL_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; +export const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; + +/** + * How to answer each Cognito auth challenge. + * + * Adding support for a new challenge type is adding a row here -- no routing, + * dispatch or flow changes. That is what makes enabling MFA on the user pool a + * configuration change rather than a code change: SOFTWARE_TOKEN_MFA, SMS_MFA, + * EMAIL_OTP and SELECT_MFA_TYPE are already wired and become reachable the + * moment mfa_configuration is turned on in infrastructure/aws/cognito.tf. + */ +interface ChallengeSpec { + /** Body fields that must be present for this challenge. */ + required: string[]; + /** Builds the Cognito ChallengeResponses map. */ + build: (body: Record, username: string) => Record; +} + +export const CHALLENGE_SPECS: Record = { + NEW_PASSWORD_REQUIRED: { + required: ['newPassword'], + build: (body, username) => ({ + USERNAME: username, + NEW_PASSWORD: String(body.newPassword), + ...(body.name ? { 'userAttributes.name': String(body.name) } : {}), + }), + }, + SOFTWARE_TOKEN_MFA: { + required: ['code'], + build: (body, username) => ({ + USERNAME: username, + SOFTWARE_TOKEN_MFA_CODE: String(body.code), + }), + }, + SMS_MFA: { + required: ['code'], + build: (body, username) => ({ + USERNAME: username, + SMS_MFA_CODE: String(body.code), + }), + }, + EMAIL_OTP: { + required: ['code'], + build: (body, username) => ({ + USERNAME: username, + EMAIL_OTP_CODE: String(body.code), + }), + }, + SELECT_MFA_TYPE: { + required: ['mfaType'], + build: (body, username) => ({ + USERNAME: username, + ANSWER: String(body.mfaType), + }), + }, +}; + +/** + * Password rules, kept in one place so /register and /respond-challenge cannot + * drift. Returns an error message, or null when the password is acceptable. + * Mirrors the pool's password_policy in infrastructure/aws/cognito.tf. + */ +export function validatePassword(password: unknown): string | null { + if (typeof password !== 'string') return 'Password must be a string'; + if (password.length < 8) return 'Password must be at least 8 characters long'; + if (!/[a-z]/.test(password)) return 'Password must contain at least one lowercase letter'; + if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter'; + if (!/[0-9]/.test(password)) return 'Password must contain at least one number'; + return null; +} + +/** 200 + the token set. Shape matches what the frontend AuthContext expects. */ +export function authResultResponse(result: AuthenticationResultType): APIGatewayProxyResult { + return json(200, { + AccessToken: result.AccessToken, + IdToken: result.IdToken, + // Absent on REFRESH_TOKEN_AUTH: Cognito does not re-issue a refresh token. + RefreshToken: result.RefreshToken, + ExpiresIn: result.ExpiresIn, + TokenType: result.TokenType, + }); +} + +/** + * 200 + the challenge to answer next. The opaque Session is valid across + * processes, so the client can complete it with a separate request to + * POST /auth/respond-challenge. + */ +export function challengeResponse( + response: InitiateAuthCommandOutput | RespondToAuthChallengeCommandOutput, +): APIGatewayProxyResult { + return json(200, { + ChallengeName: response.ChallengeName, + Session: response.Session, + ChallengeParameters: response.ChallengeParameters, + message: `Additional authentication step required: ${response.ChallengeName}`, + }); +} + +/** Single Cognito error -> HTTP mapping, shared by login, challenge and refresh. */ +export function mapCognitoAuthError( + error: any, + stage: 'login' | 'challenge' | 'refresh', +): APIGatewayProxyResult { + console.error(`Cognito ${stage} error:`, error); + const code = error?.name; + + switch (code) { + case 'NotAuthorizedException': { + const message = + stage === 'refresh' + ? 'Refresh token is invalid or expired' + : stage === 'challenge' + ? 'Challenge session is invalid or expired, please sign in again' + : 'Invalid email or password'; + return json(401, { message, code }); + } + // prevent_user_existence_errors is ENABLED on the app client, so Cognito + // normally folds this into NotAuthorizedException. Handled for parity. + case 'UserNotFoundException': + return json(401, { message: 'Invalid email or password', code }); + case 'UserNotConfirmedException': + return json(403, { message: 'Email not verified', code }); + case 'PasswordResetRequiredException': + return json(403, { message: 'Password reset required', code }); + case 'CodeMismatchException': + return json(400, { message: 'Invalid verification code', code }); + case 'ExpiredCodeException': + return json(400, { message: 'Verification code has expired', code }); + case 'InvalidPasswordException': + return json(400, { + message: + 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)', + code, + }); + case 'InvalidParameterException': + return json(400, { message: error?.message || 'Invalid parameters provided', code }); + case 'TooManyRequestsException': + case 'LimitExceededException': + case 'TooManyFailedAttemptsException': + return json(429, { message: 'Too many attempts, please try again later', code }); + case 'ForbiddenException': + return json(403, { message: 'Request blocked', code }); + default: + return json(500, { message: 'Authentication failed', error: error?.message, code }); + } +} diff --git a/apps/backend/lambdas/auth/tsconfig.json b/apps/backend/lambdas/auth/tsconfig.json index d35b2baa..c63669f7 100644 --- a/apps/backend/lambdas/auth/tsconfig.json +++ b/apps/backend/lambdas/auth/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts", "services/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } From 10d2bd893b833043be4695c8eaa3d1c247939718 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Aug 2026 18:34:59 +0000 Subject: [PATCH 07/20] chore: regenerate lambda READMEs --- apps/backend/lambdas/users/README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/backend/lambdas/users/README.md b/apps/backend/lambdas/users/README.md index 80d91408..d25fbacb 100644 --- a/apps/backend/lambdas/users/README.md +++ b/apps/backend/lambdas/users/README.md @@ -9,11 +9,6 @@ Lambda for managing users. | Method | Path | Description | |--------|------|-------------| | GET | /health | Health check | -| GET | /users | | -| GET | /{userId} | | -| PATCH | /{userId} | | -| DELETE | /users/{userId} | | -| POST | /users | | ## Setup From 84eb1212c70a2e957f143528ce1bd39668f791a3 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 19:28:50 -0400 Subject: [PATCH 08/20] fix(users): build @branch/lambda-http in the Docker image The lambda declares @branch/lambda-http as a file: dependency, but the Dockerfile only copied and built shared/lambda-auth, so npm install inside the image resolved a path that was never copied and `make up` failed at build time. Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves lambda-auth as file:../lambda-auth and consumes its dist. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/users/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/backend/lambdas/users/Dockerfile b/apps/backend/lambdas/users/Dockerfile index 0560151b..e4bc86f6 100644 --- a/apps/backend/lambdas/users/Dockerfile +++ b/apps/backend/lambdas/users/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/users/package*.json ./ RUN npm install --no-package-lock From bd480c6da3a677c0893d7ee36d98bebbe6c91693 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 19:30:26 -0400 Subject: [PATCH 09/20] ci: one composite action for the shared lambda package builds Preview deploys were failing at esbuild with "Could not resolve @branch/lambda-http" while lambda-tests and lambda-deploy were green. Three workflows each encoded their own copy of "build the shared packages a lambda depends on before packaging it", and adding @branch/lambda-http updated only two of them. preview-env.yml still built lambda-auth alone, so the lambda's npm ci installed a file: dependency whose dist had never been built and the bundle could not resolve the import. Replaces all of it with .github/actions/build-shared-packages, used by lambda-tests (test + shared-http), lambda-deploy (build) and preview-env (deploy). Build order lives in one place now: lambda-http declares lambda-auth as file:../lambda-auth and compiles against its dist, so it goes second. The next shared package added is the actual test of this: one edit instead of four, with no fourth copy left to forget. Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/build-shared-packages/action.yml | 27 +++++++++++++++++++ .github/workflows/lambda-deploy.yml | 5 +--- .github/workflows/lambda-tests.yml | 10 ++----- .github/workflows/preview-env.yml | 5 +++- 4 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 .github/actions/build-shared-packages/action.yml diff --git a/.github/actions/build-shared-packages/action.yml b/.github/actions/build-shared-packages/action.yml new file mode 100644 index 00000000..883e622a --- /dev/null +++ b/.github/actions/build-shared-packages/action.yml @@ -0,0 +1,27 @@ +name: Build shared lambda packages +description: > + Install and build the shared packages the lambdas consume as file: + dependencies. Both compile to a gitignored dist/, so anything that typechecks, + tests, or bundles a lambda has to run this first or the file: path resolves to + a package with no dist. + + Single source of truth on purpose: this logic lived in lambda-tests, + lambda-deploy and preview-env separately, and adding @branch/lambda-http + updated only the first two -- so preview deploys failed at esbuild with + "Could not resolve @branch/lambda-http" while the other two were green. + + Requires actions/checkout to have run. + +runs: + using: composite + steps: + # Order matters: lambda-http declares lambda-auth as file:../lambda-auth + # and compiles against its dist. + - name: Build shared packages + shell: bash + run: | + set -euo pipefail + npm ci --prefix shared/lambda-auth --no-audit --no-fund + npm run build --prefix shared/lambda-auth + npm ci --prefix shared/lambda-http --no-audit --no-fund + npm run build --prefix shared/lambda-http diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index 3db01586..72090316 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -126,11 +126,8 @@ jobs: with: node-version: '20' - # Order matters: lambda-http consumes lambda-auth's dist. - name: Build shared packages - run: | - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + uses: ./.github/actions/build-shared-packages - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index d5f050f6..959ec366 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -56,11 +56,8 @@ jobs: run: npm ci --no-audit --no-fund && npm run migrate && npm run seed env: DATABASE_URL: postgres://branch_dev:password@localhost:5432/branch_db - # Order matters: lambda-http consumes lambda-auth's dist. - name: Build shared packages - run: | - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + uses: ./.github/actions/build-shared-packages - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps @@ -266,11 +263,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - # Order matters: lambda-http consumes lambda-auth's dist. - name: Build shared packages - run: | - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + uses: ./.github/actions/build-shared-packages - name: Run tests run: npm test --prefix shared/lambda-http diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 69e53e61..a5e37da9 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -212,11 +212,14 @@ jobs: echo "lambdas=$(echo $lambdas | xargs)" >> "$GITHUB_OUTPUT" echo "frontend=$frontend" >> "$GITHUB_OUTPUT" + - name: Build shared packages + if: steps.detect.outputs.lambdas != '' + uses: ./.github/actions/build-shared-packages + - name: Build + deploy lambdas if: steps.detect.outputs.lambdas != '' run: | set -euo pipefail - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth for svc in ${{ steps.detect.outputs.lambdas }}; do echo "::group::lambda $svc" ( cd "apps/backend/lambdas/$svc" && npm ci --legacy-peer-deps && npm run package ) From 5fb5025b836a5b5835a96a4289796c94f268cd87 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 19:33:56 -0400 Subject: [PATCH 10/20] fix(lambda-cli): read routes from routes.ts so READMEs survive the conversion The lambda-readme workflow regenerates every README and pushes the result, and it ran the old CLI against a converted lambda: extractRoutesFromHandler parses if-conditions out of handler.ts, which is now four lines, so it found no routes and auto-committed a README with all five of users' endpoints deleted. - extractRoutesFromHandler now prefers a sibling routes.ts and falls back to the if-chain parse, so it reads converted and unconverted lambdas alike. That matters inside this stack, where only some lambdas have been converted at any given commit. - collectRoutes lists health once, under the service prefix for a converted lambda and bare otherwise, instead of hardcoding /health and duplicating a spec entry that spells it the other way. - users/openapi.yaml is normalized to match: paths carry the /users prefix and servers is the bare host. It previously contradicted itself -- servers ended in /users AND the /users path was prefixed, so Swagger built /users/users, while /{userId} had no prefix at all. The fuller CLI rebuild lands in 8/8; this is the subset needed for the README workflow to stop rewriting these files as each lambda converts. expenditures/README.md picks up a POST /expenditures row: pre-existing drift on main that the workflow would have auto-committed anyway. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/expenditures/README.md | 1 + apps/backend/lambdas/tools/lambda-cli.js | 38 +++++++++++++++++++-- apps/backend/lambdas/users/README.md | 7 +++- apps/backend/lambdas/users/openapi.yaml | 6 ++-- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 0f23e38a..0698e3ec 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -15,6 +15,7 @@ Lambda for tracking project expenditures. | GET | /expenditures/{id} | | | DELETE | /expenditures/{id} | | | PATCH | /expenditures/{id}/status | | +| POST | /expenditures | | ## Setup diff --git a/apps/backend/lambdas/tools/lambda-cli.js b/apps/backend/lambdas/tools/lambda-cli.js index 51c2013e..460e5435 100644 --- a/apps/backend/lambdas/tools/lambda-cli.js +++ b/apps/backend/lambdas/tools/lambda-cli.js @@ -786,10 +786,36 @@ function normalizePathForComparison(path) { } // Extract routes from handler.ts +// Reads the route table a converted lambda declares in routes.ts. Returns null +// when there is no table, so callers fall back to parsing handler.ts. +function extractRoutesFromRoutesTable(handlerPath) { + const routesPath = path.join(path.dirname(handlerPath), 'routes.ts'); + if (!fs.existsSync(routesPath)) return null; + + const source = fs.readFileSync(routesPath, 'utf8'); + const routes = []; + const entryRegex = /method:\s*['"]([A-Za-z]+)['"]\s*,\s*pattern:\s*['"]([^'"]+)['"]/g; + + let match; + while ((match = entryRegex.exec(source)) !== null) { + // `:param` is the router's spelling; READMEs and the OpenAPI specs use {param}. + const routePath = match[2].replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + routes.push({ method: match[1].toUpperCase(), path: routePath }); + } + + return routes; +} + function extractRoutesFromHandler(handlerPath) { + // Converted lambdas keep their routes in routes.ts; the if-chain parsing below + // finds nothing in their four-line handler.ts and would silently report zero + // routes, which is how the README workflow came to delete them. + const tableRoutes = extractRoutesFromRoutesTable(handlerPath); + if (tableRoutes) return tableRoutes; + const source = fs.readFileSync(handlerPath, 'utf8'); const routes = []; - + // Find the routes section between ROUTES-START and ROUTES-END const startMarker = '// >>> ROUTES-START'; const endMarker = '// <<< ROUTES-END'; @@ -1160,9 +1186,15 @@ function collectRoutes(handlerPath, openapiPath) { } } - const routes = [{ method: 'GET', path: '/health', description: 'Health check' }]; + // A converted lambda serves health centrally under its prefix, and its spec + // says so; an unconverted one still declares a bare /health. Follow whichever + // applies, and skip both spellings below so only one row is emitted. + const service = path.basename(path.dirname(handlerPath)); + const converted = fs.existsSync(path.join(path.dirname(handlerPath), 'routes.ts')); + const healthPath = converted ? `/${service}/health` : '/health'; + const routes = [{ method: 'GET', path: healthPath, description: 'Health check' }]; for (const route of routeMap.values()) { - if (route.method === 'GET' && route.path === '/health') continue; + if (route.method === 'GET' && (route.path === '/health' || route.path === healthPath)) continue; routes.push({ method: route.method, path: route.path, description: '' }); } return routes; diff --git a/apps/backend/lambdas/users/README.md b/apps/backend/lambdas/users/README.md index d25fbacb..6e513a96 100644 --- a/apps/backend/lambdas/users/README.md +++ b/apps/backend/lambdas/users/README.md @@ -8,7 +8,12 @@ Lambda for managing users. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /users/health | Health check | +| GET | /users | | +| GET | /users/{userId} | | +| PATCH | /users/{userId} | | +| DELETE | /users/{userId} | | +| POST | /users | | ## Setup diff --git a/apps/backend/lambdas/users/openapi.yaml b/apps/backend/lambdas/users/openapi.yaml index fa4d4413..741be558 100644 --- a/apps/backend/lambdas/users/openapi.yaml +++ b/apps/backend/lambdas/users/openapi.yaml @@ -3,9 +3,9 @@ info: title: users (Local) version: 1.0.0 servers: - - url: http://localhost:3000/users + - url: http://localhost:3000 paths: - /health: + /users/health: get: summary: Health check responses: @@ -58,7 +58,7 @@ paths: description: Bad Request '500': description: Internal Server Error - /{userId}: + /users/{userId}: get: summary: GET /users/{userId} parameters: From e5b1e6fff4416787197d3d2d212b8dba2721b38a Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 19:34:35 -0400 Subject: [PATCH 11/20] fix(donors): build @branch/lambda-http in the Docker image The lambda declares @branch/lambda-http as a file: dependency, but the Dockerfile only copied and built shared/lambda-auth, so npm install inside the image resolved a path that was never copied and `make up` failed at build time. Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves lambda-auth as file:../lambda-auth and consumes its dist. README regenerated so the lambda-readme workflow has nothing to push. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/donors/Dockerfile | 6 ++++++ apps/backend/lambdas/donors/README.md | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/backend/lambdas/donors/Dockerfile b/apps/backend/lambdas/donors/Dockerfile index 55811090..6d566340 100644 --- a/apps/backend/lambdas/donors/Dockerfile +++ b/apps/backend/lambdas/donors/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/donors/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index 30ce2bdc..453b1347 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -8,11 +8,14 @@ Lambda for managing donors. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /donors/health | Health check | | GET | /donors | | -| POST | /donations | | +| GET | /donors/donations | | +| POST | /donors/donations | | | POST | /donors | | | DELETE | /donors/{id} | | +| DELETE | /donors/donations/{id} | | +| POST | /donations | | | DELETE | /donations/{id} | | ## Setup From ef185a83443b4781ee5eb88bd62d6383c8fb6c29 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Aug 2026 23:34:38 +0000 Subject: [PATCH 12/20] chore: regenerate lambda READMEs --- apps/backend/lambdas/expenditures/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 0698e3ec..0f23e38a 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -15,7 +15,6 @@ Lambda for tracking project expenditures. | GET | /expenditures/{id} | | | DELETE | /expenditures/{id} | | | PATCH | /expenditures/{id}/status | | -| POST | /expenditures | | ## Setup From e1949a12b2e8b0d8c88700a0963f90331cc8eb3e Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 19:34:56 -0400 Subject: [PATCH 13/20] fix(reports): build @branch/lambda-http in the Docker image The lambda declares @branch/lambda-http as a file: dependency, but the Dockerfile only copied and built shared/lambda-auth, so npm install inside the image resolved a path that was never copied and `make up` failed at build time. Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves lambda-auth as file:../lambda-auth and consumes its dist. README regenerated so the lambda-readme workflow has nothing to push. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/reports/Dockerfile | 6 ++++++ apps/backend/lambdas/reports/README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/backend/lambdas/reports/Dockerfile b/apps/backend/lambdas/reports/Dockerfile index 25f88a0f..2e2bd306 100644 --- a/apps/backend/lambdas/reports/Dockerfile +++ b/apps/backend/lambdas/reports/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/reports/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/reports/README.md b/apps/backend/lambdas/reports/README.md index f02cd638..0c3736c5 100644 --- a/apps/backend/lambdas/reports/README.md +++ b/apps/backend/lambdas/reports/README.md @@ -8,7 +8,7 @@ TODO: Add a description of the reports lambda. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /reports/health | Health check | | POST | /reports/generate | | | GET | /reports | | | GET | /reports/upload-url | | From 05bcbfc11ce4ee26605eae1ae084de779862d5f6 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 19:35:16 -0400 Subject: [PATCH 14/20] fix(expenditures): build @branch/lambda-http in the Docker image The lambda declares @branch/lambda-http as a file: dependency, but the Dockerfile only copied and built shared/lambda-auth, so npm install inside the image resolved a path that was never copied and `make up` failed at build time. Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves lambda-auth as file:../lambda-auth and consumes its dist. README regenerated so the lambda-readme workflow has nothing to push. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/expenditures/Dockerfile | 6 ++++++ apps/backend/lambdas/expenditures/README.md | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/backend/lambdas/expenditures/Dockerfile b/apps/backend/lambdas/expenditures/Dockerfile index 40e719df..b34ac87d 100644 --- a/apps/backend/lambdas/expenditures/Dockerfile +++ b/apps/backend/lambdas/expenditures/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/expenditures/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 0698e3ec..0b434671 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -8,14 +8,14 @@ Lambda for tracking project expenditures. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /expenditures/health | Health check | | GET | /expenditures | | +| POST | /expenditures | | | GET | /expenditures/upload-url | | | GET | /expenditures/{id}/receipt | | | GET | /expenditures/{id} | | | DELETE | /expenditures/{id} | | | PATCH | /expenditures/{id}/status | | -| POST | /expenditures | | ## Setup From 535118b879061944075b7fe1e41ea36eeaf804b4 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sat, 22 Aug 2026 19:35:37 -0400 Subject: [PATCH 15/20] fix(auth): build @branch/lambda-http in the Docker image The lambda declares @branch/lambda-http as a file: dependency, but the Dockerfile only copied and built shared/lambda-auth, so npm install inside the image resolved a path that was never copied and `make up` failed at build time. Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves lambda-auth as file:../lambda-auth and consumes its dist. README regenerated so the lambda-readme workflow has nothing to push. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/auth/Dockerfile | 6 ++++++ apps/backend/lambdas/auth/README.md | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/backend/lambdas/auth/Dockerfile b/apps/backend/lambdas/auth/Dockerfile index 3b62f649..5a8a986f 100644 --- a/apps/backend/lambdas/auth/Dockerfile +++ b/apps/backend/lambdas/auth/Dockerfile @@ -11,6 +11,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app # Copy package files diff --git a/apps/backend/lambdas/auth/README.md b/apps/backend/lambdas/auth/README.md index 998cb351..f7b5d9e9 100644 --- a/apps/backend/lambdas/auth/README.md +++ b/apps/backend/lambdas/auth/README.md @@ -8,7 +8,17 @@ Lambda for auth handler. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /auth/health | Health check | +| POST | /auth/register | | +| POST | /auth/login | | +| POST | /auth/respond-challenge | | +| POST | /auth/refresh | | +| GET | /auth/me | | +| POST | /auth/verify-email | | +| POST | /auth/resend-code | | +| POST | /auth/logout | | +| POST | /auth/forgot-password | | +| POST | /auth/reset-password | | | POST | /register | | | POST | /login | | | POST | /respond-challenge | | From eb6d2ec7fbcf9791cd9cf7ab642272c542bc3cbd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Aug 2026 23:36:08 +0000 Subject: [PATCH 16/20] chore: regenerate lambda READMEs --- apps/backend/lambdas/donors/README.md | 2 -- apps/backend/lambdas/expenditures/README.md | 1 - 2 files changed, 3 deletions(-) diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index 453b1347..c2819f43 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -15,8 +15,6 @@ Lambda for managing donors. | POST | /donors | | | DELETE | /donors/{id} | | | DELETE | /donors/donations/{id} | | -| POST | /donations | | -| DELETE | /donations/{id} | | ## Setup diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 0698e3ec..0f23e38a 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -15,7 +15,6 @@ Lambda for tracking project expenditures. | GET | /expenditures/{id} | | | DELETE | /expenditures/{id} | | | PATCH | /expenditures/{id}/status | | -| POST | /expenditures | | ## Setup From 58e344b0bfb511848c8356546cba24179c8cb7c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Aug 2026 23:36:18 +0000 Subject: [PATCH 17/20] chore: regenerate lambda READMEs --- apps/backend/lambdas/donors/README.md | 2 -- apps/backend/lambdas/expenditures/README.md | 1 - 2 files changed, 3 deletions(-) diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index 453b1347..c2819f43 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -15,8 +15,6 @@ Lambda for managing donors. | POST | /donors | | | DELETE | /donors/{id} | | | DELETE | /donors/donations/{id} | | -| POST | /donations | | -| DELETE | /donations/{id} | | ## Setup diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 0698e3ec..0f23e38a 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -15,7 +15,6 @@ Lambda for tracking project expenditures. | GET | /expenditures/{id} | | | DELETE | /expenditures/{id} | | | PATCH | /expenditures/{id}/status | | -| POST | /expenditures | | ## Setup From b3a747e2452ec050a7bed12f2d9e8781da795787 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Aug 2026 23:37:14 +0000 Subject: [PATCH 18/20] chore: regenerate lambda READMEs --- apps/backend/lambdas/donors/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index 453b1347..c2819f43 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -15,8 +15,6 @@ Lambda for managing donors. | POST | /donors | | | DELETE | /donors/{id} | | | DELETE | /donors/donations/{id} | | -| POST | /donations | | -| DELETE | /donations/{id} | | ## Setup From b9b5d7b6359a46c35ddc1867cd0b352796d3f376 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Aug 2026 23:38:14 +0000 Subject: [PATCH 19/20] chore: regenerate lambda READMEs --- apps/backend/lambdas/auth/README.md | 10 ---------- apps/backend/lambdas/donors/README.md | 2 -- 2 files changed, 12 deletions(-) diff --git a/apps/backend/lambdas/auth/README.md b/apps/backend/lambdas/auth/README.md index f7b5d9e9..66ffd1b0 100644 --- a/apps/backend/lambdas/auth/README.md +++ b/apps/backend/lambdas/auth/README.md @@ -19,16 +19,6 @@ Lambda for auth handler. | POST | /auth/logout | | | POST | /auth/forgot-password | | | POST | /auth/reset-password | | -| POST | /register | | -| POST | /login | | -| POST | /respond-challenge | | -| POST | /refresh | | -| GET | /me | | -| POST | /verify-email | | -| POST | /resend-code | | -| POST | /logout | | -| POST | /forgot-password | | -| POST | /reset-password | | ## Setup diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index 453b1347..c2819f43 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -15,8 +15,6 @@ Lambda for managing donors. | POST | /donors | | | DELETE | /donors/{id} | | | DELETE | /donors/donations/{id} | | -| POST | /donations | | -| DELETE | /donations/{id} | | ## Setup From 5c1c3c74a3fcbf506aa8e92a32be1696928db7af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 23 Aug 2026 00:29:57 +0000 Subject: [PATCH 20/20] chore: regenerate lambda READMEs --- apps/backend/lambdas/auth/README.md | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/backend/lambdas/auth/README.md b/apps/backend/lambdas/auth/README.md index e08505a3..90ba6bee 100644 --- a/apps/backend/lambdas/auth/README.md +++ b/apps/backend/lambdas/auth/README.md @@ -8,21 +8,21 @@ Lambda for auth handler. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | -| POST | /register | | -| POST | /login | | -| POST | /respond-challenge | | -| POST | /refresh | | -| GET | /me | | -| POST | /verify-email | | -| POST | /resend-code | | -| POST | /logout | | -| POST | /forgot-password | | -| POST | /reset-password | | -| POST | /mfa-setup | | -| POST | /mfa-verify | | -| POST | /mfa-disable | | -| GET | /mfa-status | | +| GET | /auth/health | Health check | +| POST | /auth/register | | +| POST | /auth/login | | +| POST | /auth/respond-challenge | | +| POST | /auth/refresh | | +| GET | /auth/me | | +| POST | /auth/verify-email | | +| POST | /auth/resend-code | | +| POST | /auth/logout | | +| POST | /auth/forgot-password | | +| POST | /auth/reset-password | | +| POST | /auth/mfa-setup | | +| POST | /auth/mfa-verify | | +| POST | /auth/mfa-disable | | +| GET | /auth/mfa-status | | ## Setup