diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index e54870d6..7a07a217 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -68,9 +68,28 @@ jobs: COGNITO_CLIENT_ID: ${{ secrets.COGNITO_CLIENT_ID }} AWS_REGION: us-east-2 + # The discover job only globs apps/backend/lambdas/*/, so the shared auth + # package -- the single most security-critical module in the backend -- would + # never have its tests run without this job. + shared-auth: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies + run: npm ci --prefix shared/lambda-auth + - name: Run tests + run: npm test --prefix shared/lambda-auth + + # 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 + needs: [test, shared-auth] if: always() runs-on: ubuntu-latest steps: @@ -80,4 +99,8 @@ jobs: echo "Lambda tests failed or were cancelled" exit 1 fi + if [ "${{ needs.shared-auth.result }}" != "success" ]; then + echo "shared/lambda-auth tests failed or were cancelled" + exit 1 + fi echo "All lambda tests passed!" diff --git a/apps/backend/.env.example b/apps/backend/.env.example index e3b0ff63..c917873d 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -5,9 +5,6 @@ DB_USER=branch_dev DB_PASSWORD=password DB_NAME=branch_db -# Auth Configuration -JWT_SECRET=dev-secret-change-in-production - # Service Ports (external) USERS_PORT=3001 PROJECTS_PORT=3002 @@ -17,8 +14,13 @@ REPORTS_PORT=3005 AUTH_PORT=3006 # Cognito Configuration -COGNITO_CLIENT_ID=secret -COGNITO_USER_POOL_ID=secret +# These MUST be the real shared dev-pool values: local auth talks to the real +# Cognito pool (JWKS fetch for token verification, InitiateAuth for sign-in). +# No AWS credentials are needed -- every Cognito API used on the sign-in path is +# unsigned. Get the values with: +# cd infrastructure/aws && terraform output cognito_user_pool_id cognito_client_id +COGNITO_CLIENT_ID= +COGNITO_USER_POOL_ID= # AWS Configuration S3_BUCKET_NAME=name diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 3b73fc4b..e4158926 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -64,4 +64,18 @@ Automatic on push to `main` touching `apps/backend/lambdas/**` or `shared/types/ ## Env vars (lambdas) -`DB_HOST DB_PORT DB_USER DB_PASSWORD DB_NAME`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID` (or `COGNITO_APP_CLIENT_ID`), `AWS_REGION` (default `us-east-2`). +`DB_HOST DB_PORT DB_USER DB_PASSWORD DB_NAME`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID` (or `COGNITO_APP_CLIENT_ID`), `REPORTS_BUCKET_NAME` (reports only), `AWS_REGION` (default `us-east-2`, Lambda-reserved — never set it in Terraform). + +**Anything a lambda reads from `process.env` must be declared in the `environment` block of `infrastructure/aws/lambda.tf`.** That block is authoritative and is deliberately not in `lifecycle.ignore_changes`, so a value set by hand in the console is deleted on the next apply. Locally the Cognito values must be the real shared dev-pool IDs (`apps/backend/.env`) — auth talks to the real pool for JWKS and `InitiateAuth` — but no AWS credentials are needed, because every Cognito API on the sign-in path is unsigned. + +## Auth + +`shared/lambda-auth` verifies the Bearer **access** token with `aws-jwt-verify`, then looks the caller up in `branch.users` by `cognito_sub`. A valid Cognito token whose sub has no DB row is treated as unauthenticated. + +**`branch.users.is_admin` is the single source of truth for admin.** There is no promotion from a Cognito group, and no pre-token-generation trigger, so `is_admin` is not a JWT claim — `GET /auth/me` is the only way a client can learn it. + +**A `branch.users` row with `cognito_sub IS NULL` is a pending invitation**, created by the `db_setup.sql` seeds or by admin `POST /users`. `POST /auth/register` claims such a row (setting `cognito_sub`, never touching `is_admin`) instead of returning 409. Registration only 409s when the row is already claimed. + +**Bootstrapping the first admin** is a manual SQL statement in every environment, because `is_admin` can only be set by an existing admin: `make grant-admin EMAIL=…` locally, or the equivalent `UPDATE` against RDS in production. + +The auth lambda uses `USER_PASSWORD_AUTH` via the AWS SDK, not the SRP library. `POST /auth/login` returns either a token set or `{ ChallengeName, Session }`; `POST /auth/respond-challenge` completes it. Adding a challenge type is a row in `CHALLENGE_SPECS` — enabling MFA is a Terraform change, not a code change. diff --git a/apps/backend/Makefile b/apps/backend/Makefile index d4961b68..937be50e 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -75,6 +75,17 @@ clean: db-shell: docker compose exec postgres psql -U branch_dev -d branch_db +# Promote a locally-registered user to admin. LOCAL DEV ONLY. +# There is deliberately no API for this: branch.users.is_admin can only be set by +# an existing admin (PATCH /users/{userId}), so the first admin in any fresh +# environment has to be bootstrapped in SQL. The same chicken-and-egg exists in +# production -- see apps/backend/AGENTS.md. +# usage: make grant-admin EMAIL=you@example.com +grant-admin: + @test -n "$(EMAIL)" || (echo "usage: make grant-admin EMAIL=you@example.com" && exit 1) + docker compose exec -T postgres psql -U branch_dev -d branch_db \ + -c "UPDATE branch.users SET is_admin = TRUE WHERE email = lower('$(EMAIL)');" + # Reset database (WARNING: destroys all data) db-reset: @echo "WARNING: This will destroy all data in the database!" @@ -97,5 +108,6 @@ help: @echo " make health - Check health of all services" @echo " make clean - Clean up Docker resources" @echo " make db-shell - Open PostgreSQL shell" + @echo " make grant-admin EMAIL= - Promote a local user to admin" @echo " make db-reset - Reset database (WARNING: destroys data)" @echo " make help - Show this help message" diff --git a/apps/backend/db/db_setup.sql b/apps/backend/db/db_setup.sql index 7be7a2e4..16163e8c 100644 --- a/apps/backend/db/db_setup.sql +++ b/apps/backend/db/db_setup.sql @@ -72,6 +72,15 @@ CREATE TABLE reports ( date_created DATE NOT NULL DEFAULT CURRENT_DATE ); +-- These seeded admins intentionally have cognito_sub = NULL. A NULL cognito_sub +-- means "pending invitation": POST /auth/register signs the email up in Cognito +-- and CLAIMS this row (setting cognito_sub) rather than returning 409, which +-- preserves user_id and is_admin. The same mechanism backs admin-created users +-- (POST /users), which also insert without a cognito_sub. +-- +-- To sign in as one of these locally you must control the mailbox to receive the +-- Cognito verification code. Otherwise register your own email and run +-- `make grant-admin EMAIL=you@example.com`. INSERT INTO users (name, email, is_admin) VALUES ('Ashley Duggan', 'ashley@branch.org', TRUE), ('Renee Reddy', 'renee@branch.org', TRUE), diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 85aa3a3a..1eb64c9a 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -79,7 +79,7 @@ services: DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-branch_db} COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} - COGNITO_APP_CLIENT_ID: ${COGNITO_CLIENT_ID} + COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID} ports: - '3003:3000' depends_on: @@ -145,7 +145,6 @@ services: DB_USER: ${DB_USER:-branch_dev} DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-branch_db} - JWT_SECRET: ${JWT_SECRET:-dev-secret-change-in-production} COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID} COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} ports: diff --git a/apps/backend/lambdas/auth/README.md b/apps/backend/lambdas/auth/README.md index 4efd4f14..998cb351 100644 --- a/apps/backend/lambdas/auth/README.md +++ b/apps/backend/lambdas/auth/README.md @@ -11,6 +11,9 @@ Lambda for auth handler. | GET | /health | Health check | | POST | /register | | | POST | /login | | +| POST | /respond-challenge | | +| POST | /refresh | | +| GET | /me | | | POST | /verify-email | | | POST | /resend-code | | | POST | /logout | | diff --git a/apps/backend/lambdas/auth/auth.ts b/apps/backend/lambdas/auth/auth.ts new file mode 100644 index 00000000..7c97bfc9 --- /dev/null +++ b/apps/backend/lambdas/auth/auth.ts @@ -0,0 +1,10 @@ +import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth'; +import db from './db'; + +export * from '@branch/lambda-auth'; + +export async function authenticateRequest( + event: any, +): Promise { + return _authenticateRequest(db, event); +} diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 5136c431..120b2425 100644 --- a/apps/backend/lambdas/auth/handler.ts +++ b/apps/backend/lambdas/auth/handler.ts @@ -4,8 +4,12 @@ import { SignUpCommand, SignUpCommandInput, AdminDeleteUserCommand, + AdminGetUserCommand, InitiateAuthCommand, InitiateAuthCommandInput, + InitiateAuthCommandOutput, + RespondToAuthChallengeCommand, + RespondToAuthChallengeCommandOutput, ConfirmSignUpCommand, ConfirmSignUpCommandInput, ResendConfirmationCodeCommand, @@ -16,8 +20,10 @@ import { ForgotPasswordCommandInput, ConfirmForgotPasswordCommand, ConfirmForgotPasswordCommandInput, + AuthenticationResultType, + ChallengeNameType, } from '@aws-sdk/client-cognito-identity-provider'; -import { CognitoUser, CognitoUserPool, AuthenticationDetails } from 'amazon-cognito-identity-js'; +import { authenticateRequest } from './auth'; import db from './db'; // Initialize Cognito client (region defaults to us-east-2) @@ -28,6 +34,61 @@ const cognitoClient = new CognitoIdentityProviderClient({ 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 @@ -63,7 +124,22 @@ export const handler = async (event: any): Promise => { 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 : {}; @@ -242,11 +318,122 @@ export const handler = async (event: any): Promise => { } }; -async function handleLogin(event: any): Promise { - let body: Record; +/** Parses a JSON body, returning null when it is not valid JSON. */ +function parseBody(event: any): Record | null { try { - body = event.body ? JSON.parse(event.body) as Record : {}; - } catch (e) { + 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' }); } @@ -255,46 +442,181 @@ async function handleLogin(event: any): Promise { return json(400, { message: 'email and password are required' }); } - const userPool = new CognitoUserPool({ - UserPoolId: USER_POOL_ID, + // 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) }, + }; - const cognitoUser = new CognitoUser({ - Username: email as string, - Pool: userPool, - }); + try { + const response = await cognitoClient.send(new InitiateAuthCommand(params)); - const authDetails = new AuthenticationDetails({ - Username: email as string, - Password: password as string, - }); + if (response.AuthenticationResult) { + return authResultResponse(response.AuthenticationResult); + } - return new Promise((resolve) => { - cognitoUser.authenticateUser(authDetails, { - onSuccess: (result) => { - resolve(json(200, { - AccessToken: result.getAccessToken().getJwtToken(), - IdToken: result.getIdToken().getJwtToken(), - RefreshToken: result.getRefreshToken().getToken(), - })); - }, - onFailure: (err) => { - console.error('SRP auth error:', err); - if (err.code === 'UserNotConfirmedException') { - resolve(json(403, { message: 'Email not verified' })); - } else if (err.code === 'NotAuthorizedException') { - resolve(json(401, { message: 'Invalid email or password' })); - } else if (err.code === 'UserNotFoundException') { - resolve(json(401, { message: 'Invalid email or password' })); - } else { - resolve(json(500, { message: 'Authentication failed', error: err.message })); - } - }, - newPasswordRequired: (userAttributes) => { - resolve(json(403, { message: 'Password change required', userAttributes })); - }, + 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, }); } @@ -319,17 +641,9 @@ async function handleRegister(event: any): Promise { } // Validate password requirements - if (password.length < 8) { - return json(400, { message: 'Password must be at least 8 characters long' }); - } - if (!/[a-z]/.test(password)) { - return json(400, { message: 'Password must contain at least one lowercase letter' }); - } - if (!/[A-Z]/.test(password)) { - return json(400, { message: 'Password must contain at least one uppercase letter' }); - } - if (!/[0-9]/.test(password)) { - return json(400, { message: 'Password must contain at least one number' }); + const passwordError = validatePassword(password); + if (passwordError) { + return json(400, { message: passwordError }); } // Validate name @@ -337,17 +651,23 @@ async function handleRegister(event: any): Promise { return json(400, { message: 'Name must be at least 2 characters long' }); } - // Check if user already exists in database + // A branch.users row with cognito_sub IS NULL is a PENDING INVITATION, not a + // conflict. Two paths create them: the db_setup.sql seeds 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) { + if (existingUser && existingUser.cognito_sub) { return json(409, { message: 'User with this email already exists' }); } + const claimingUserId: number | null = existingUser ? existingUser.user_id : null; + // Prepare Cognito SignUp parameters const signUpParams: SignUpCommandInput = { ClientId: USER_POOL_CLIENT_ID, @@ -376,7 +696,43 @@ async function handleRegister(event: any): Promise { // Handle specific Cognito errors if (error.name === 'UsernameExistsException') { - return json(409, { message: 'User with this email already exists' }); + // 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. + if (claimingUserId !== null) { + 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') { + await db + .updateTable('branch.users') + .set({ cognito_sub: sub }) + .where('user_id', '=', claimingUserId) + .where('cognito_sub', 'is', null) + .execute(); + 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' }); @@ -388,17 +744,31 @@ async function handleRegister(event: any): Promise { return json(500, { message: 'Failed to register user in authentication service' }); } - // Create user in database + // Create user in database, or claim the pending invitation try { - await db - .insertInto('branch.users') - .values({ - cognito_sub: cognitoUserSub, - email: email.toLowerCase(), - name: name.trim(), - is_admin: false, - }) - .execute(); + if (claimingUserId !== null) { + // is_admin is deliberately NOT touched: it was set by whoever created the + // invitation (a seed, or an admin via POST /users) and must not be + // settable from a public, unauthenticated endpoint. The cognito_sub IS + // NULL predicate makes a concurrent claim a no-op rather than an + // overwrite; UNIQUE(cognito_sub) is the backstop. + await db + .updateTable('branch.users') + .set({ cognito_sub: cognitoUserSub, name: name.trim() }) + .where('user_id', '=', claimingUserId) + .where('cognito_sub', 'is', null) + .execute(); + } else { + await db + .insertInto('branch.users') + .values({ + cognito_sub: cognitoUserSub, + email: email.toLowerCase(), + name: name.trim(), + is_admin: false, + }) + .execute(); + } } catch (dbError: any) { console.error('Database insert error:', dbError); @@ -406,7 +776,7 @@ async function handleRegister(event: any): Promise { try { await cognitoClient.send( new AdminDeleteUserCommand({ - UserPoolId: process.env.COGNITO_USER_POOL_ID || '', + UserPoolId: USER_POOL_ID, Username: email.toLowerCase(), }) ); @@ -425,6 +795,7 @@ async function handleRegister(event: any): Promise { name: name.trim(), emailVerificationRequired: true, details: 'Please check your email for verification code', + ...(claimingUserId !== null ? { claimed: true } : {}), }); } catch (error: any) { console.error('Registration error:', error); @@ -439,7 +810,7 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' }, body: JSON.stringify(body) }; diff --git a/apps/backend/lambdas/auth/openapi.yaml b/apps/backend/lambdas/auth/openapi.yaml index 32b5a0d5..f95251a0 100644 --- a/apps/backend/lambdas/auth/openapi.yaml +++ b/apps/backend/lambdas/auth/openapi.yaml @@ -111,7 +111,11 @@ paths: /login: post: summary: Log in a user - description: Authenticates a user with email and password via Cognito SRP + description: > + Authenticates with email and password via Cognito USER_PASSWORD_AUTH. + A 200 is either a token set OR an auth challenge -- branch on the + presence of ChallengeName. Complete challenges at + POST /auth/respond-challenge using the returned Session. requestBody: required: true content: @@ -132,18 +136,15 @@ paths: example: Password123 responses: '200': - description: Login successful + description: Tokens, or the next auth challenge content: application/json: schema: - type: object - properties: - AccessToken: - type: string - IdToken: - type: string - RefreshToken: - type: string + oneOf: + - $ref: '#/components/schemas/TokenSet' + - $ref: '#/components/schemas/AuthChallenge' + '400': + description: email and password are required '401': description: Invalid credentials content: @@ -155,7 +156,10 @@ paths: type: string example: Invalid email or password '403': - description: Email not verified or password change required + description: > + Email not verified, password reset required, or MFA enrollment + required (MFA_SETUP, which returns a Session but is not yet + answerable) content: application/json: schema: @@ -164,6 +168,157 @@ paths: message: type: string example: Email not verified + '429': + description: Too many attempts + '500': + description: Authentication failed + + /respond-challenge: + post: + summary: Complete a Cognito auth challenge + description: > + Answers the challenge returned by POST /auth/login using the opaque + Session string. NEW_PASSWORD_REQUIRED is reachable today; + SOFTWARE_TOKEN_MFA, SMS_MFA, EMAIL_OTP and SELECT_MFA_TYPE are already + wired and become reachable as soon as mfa_configuration is enabled on + the user pool -- no code change. The response is itself either a token + set or a further challenge, because challenges chain. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - challengeName + - session + - email + properties: + challengeName: + type: string + enum: + - NEW_PASSWORD_REQUIRED + - SOFTWARE_TOKEN_MFA + - SMS_MFA + - EMAIL_OTP + - SELECT_MFA_TYPE + session: + type: string + description: Opaque Session from the /auth/login response + email: + type: string + format: email + newPassword: + type: string + format: password + description: Required for NEW_PASSWORD_REQUIRED + name: + type: string + description: > + Optional, only for NEW_PASSWORD_REQUIRED when the pool + requires the name attribute to be supplied + code: + type: string + description: Required for SOFTWARE_TOKEN_MFA / SMS_MFA / EMAIL_OTP + mfaType: + type: string + description: Required for SELECT_MFA_TYPE + responses: + '200': + description: Tokens, or the next challenge + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/TokenSet' + - $ref: '#/components/schemas/AuthChallenge' + '400': + description: Missing/invalid field, unsupported challengeName, or bad code + '401': + description: Challenge session is invalid or expired + '429': + description: Too many attempts + + /refresh: + post: + summary: Exchange a refresh token for new access and ID tokens + description: > + Cognito REFRESH_TOKEN_AUTH. No client secret is configured + (generate_secret = false) so no SECRET_HASH is required. NOTE: no new + RefreshToken is returned -- keep using the stored one until it expires + (30 days, see infrastructure/aws/cognito.tf). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - refreshToken + properties: + refreshToken: + type: string + responses: + '200': + description: New access and ID tokens + content: + application/json: + schema: + type: object + properties: + AccessToken: + type: string + IdToken: + type: string + ExpiresIn: + type: integer + example: 3600 + TokenType: + type: string + example: Bearer + '400': + description: refreshToken is required + '401': + description: Refresh token is invalid or expired + '429': + description: Too many requests + + /me: + get: + summary: Current session (canonical session bootstrap) + description: > + Verifies the Bearer access token and returns the caller's Postgres + branch.users row. This is the ONLY way the frontend can learn isAdmin: + it lives solely in Postgres and there is no pre-token-generation + trigger, so it is not a JWT claim. email and name also come from the DB + because a Cognito access token does not carry them. + security: + - bearerAuth: [] + responses: + '200': + description: Current user + content: + application/json: + schema: + type: object + properties: + userId: + type: integer + example: 1 + cognitoSub: + type: string + email: + type: string + format: email + name: + type: string + isAdmin: + type: boolean + profileImage: + type: string + nullable: true + '401': + description: Authentication required /verify-email: post: @@ -201,10 +356,26 @@ paths: /logout: post: - summary: POST /logout + summary: Sign out everywhere + description: > + Cognito GlobalSignOut on the Bearer access token. This revokes the + refresh token pool-wide for that user, so every other session for the + same account also stops being renewable. + security: + - bearerAuth: [] responses: '200': - description: OK + description: Logged out successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Logged out successfully + '401': + description: Missing Authorization header, or invalid/expired token /forgot-password: post: @@ -319,3 +490,52 @@ paths: message: type: string example: Too many attempts, please try again later + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Cognito access token, as returned by POST /auth/login + schemas: + TokenSet: + type: object + properties: + AccessToken: + type: string + IdToken: + type: string + RefreshToken: + type: string + description: Absent on POST /auth/refresh -- Cognito does not re-issue it + ExpiresIn: + type: integer + example: 3600 + TokenType: + type: string + example: Bearer + AuthChallenge: + type: object + required: + - ChallengeName + - Session + properties: + ChallengeName: + type: string + enum: + - NEW_PASSWORD_REQUIRED + - SOFTWARE_TOKEN_MFA + - SMS_MFA + - EMAIL_OTP + - SELECT_MFA_TYPE + - MFA_SETUP + Session: + type: string + description: Opaque; pass back to POST /auth/respond-challenge + ChallengeParameters: + type: object + additionalProperties: + type: string + message: + type: string diff --git a/apps/backend/lambdas/auth/package-lock.json b/apps/backend/lambdas/auth/package-lock.json index cd29d33f..51c08c83 100644 --- a/apps/backend/lambdas/auth/package-lock.json +++ b/apps/backend/lambdas/auth/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", - "amazon-cognito-identity-js": "^6.3.16", + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", "pg": "^8.17.2" @@ -30,6 +31,21 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-auth": { + "name": "@branch/lambda-auth", + "version": "1.0.0", + "dependencies": { + "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" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -677,15 +693,6 @@ } } }, - "node_modules/@aws-sdk/util-utf8-browser": { - "version": "3.259.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", - "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.3.1" - } - }, "node_modules/@aws-sdk/xml-builder": { "version": "3.972.2", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz", @@ -1205,6 +1212,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@branch/lambda-auth": { + "resolved": "../../../../shared/lambda-auth", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true @@ -3352,47 +3363,6 @@ "node": ">=0.4.0" } }, - "node_modules/amazon-cognito-identity-js": { - "version": "6.3.16", - "resolved": "https://registry.npmjs.org/amazon-cognito-identity-js/-/amazon-cognito-identity-js-6.3.16.tgz", - "integrity": "sha512-HPGSBGD6Q36t99puWh0LnptxO/4icnk2kqIQ9cTJ2tFQo5NMUnWQIgtrTAk8nm+caqUbjDzXzG56GBjI2tS6jQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "1.2.2", - "buffer": "4.9.2", - "fast-base64-decode": "^1.0.0", - "isomorphic-unfetch": "^3.0.0", - "js-cookie": "^2.2.1" - } - }, - "node_modules/amazon-cognito-identity-js/node_modules/@aws-crypto/sha256-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-1.2.2.tgz", - "integrity": "sha512-Nr1QJIbW/afYYGzYvrF70LtaHrIRtd4TNAglX8BvlfxJLZ45SAmueIKYl5tWoNBPzp65ymXGFK0Bb1vZUpuc9g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^1.2.2", - "@aws-sdk/types": "^3.1.0", - "tslib": "^1.11.1" - } - }, - "node_modules/amazon-cognito-identity-js/node_modules/@aws-crypto/util": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-1.2.2.tgz", - "integrity": "sha512-H8PjG5WJ4wz0UXAFXeJjWCW1vkvIJ3qUUD+rGRwJ2/hj+xT58Qle2MTql/2MGzkU+1JLAFuR6aJpLAjHwhmwwg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.1.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/amazon-cognito-identity-js/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3473,6 +3443,15 @@ "dev": true, "license": "MIT" }, + "node_modules/aws-jwt-verify": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-5.2.1.tgz", + "integrity": "sha512-J+buA4M+qvQDk58WXFBLkDodkEX3DDL1ac5XFQPW3opxAsaLXYu5hYnlSHsaBRDBUXBAn695kE/cw/mdyJKwJg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/axios": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", @@ -3591,26 +3570,6 @@ "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -3714,17 +3673,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4363,12 +4311,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fast-base64-decode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", - "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==", - "license": "MIT" - }, "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", @@ -4739,26 +4681,6 @@ "node": ">=10.17.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -4858,12 +4780,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4871,16 +4787,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isomorphic-unfetch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", - "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.6.1", - "unfetch": "^4.2.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -5606,12 +5512,6 @@ "node": ">= 20" } }, - "node_modules/js-cookie": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", - "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5927,26 +5827,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6904,12 +6784,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/ts-jest": { "version": "29.4.6", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", @@ -7104,12 +6978,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unfetch": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", - "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", - "license": "MIT" - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -7228,22 +7096,6 @@ "makeerror": "1.0.12" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index a91dec72..276a9c88 100644 --- a/apps/backend/lambdas/auth/package.json +++ b/apps/backend/lambdas/auth/package.json @@ -26,7 +26,8 @@ }, "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", - "amazon-cognito-identity-js": "^6.3.16", + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", "pg": "^8.17.2" diff --git a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts new file mode 100644 index 00000000..9392b91a --- /dev/null +++ b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts @@ -0,0 +1,601 @@ +/** + * Covers every Cognito SDK interaction in the auth lambda. The pre-existing + * auth.unit.test.ts only reaches code paths that return *before* any SDK call, + * which is why the login hang (a challenge with no registered callback never + * resolving its promise) shipped unnoticed. + */ +const mockSend = jest.fn(); + +jest.mock('@aws-sdk/client-cognito-identity-provider', () => { + // Keep the real command classes so mockSend.mock.calls[n][0].input is + // assertable and constructor-level input validation still runs. + const actual = jest.requireActual('@aws-sdk/client-cognito-identity-provider'); + return { + ...actual, + CognitoIdentityProviderClient: jest.fn(() => ({ send: mockSend })), + }; +}); + +const mockAuthenticateRequest = jest.fn(); +jest.mock('../auth', () => ({ + authenticateRequest: (...args: unknown[]) => mockAuthenticateRequest(...args), +})); + +const mockExecuteTakeFirst = jest.fn(); +const mockExecute = jest.fn(); +const mockSet = jest.fn(); +const mockValues = jest.fn(); + +jest.mock('../db', () => { + const selectChain: any = { + where: () => selectChain, + selectAll: () => selectChain, + select: () => selectChain, + executeTakeFirst: (...a: unknown[]) => mockExecuteTakeFirst(...a), + }; + const updateChain: any = { + set: (...a: unknown[]) => { + mockSet(...a); + return updateChain; + }, + where: () => updateChain, + execute: (...a: unknown[]) => mockExecute(...a), + }; + const insertChain: any = { + values: (...a: unknown[]) => { + mockValues(...a); + return insertChain; + }, + execute: (...a: unknown[]) => mockExecute(...a), + }; + return { + __esModule: true, + default: { + selectFrom: () => selectChain, + updateTable: () => updateChain, + insertInto: () => insertChain, + }, + }; +}); + +import { handler } from '../handler'; + +function event(path: string, method: string, body?: unknown, headers?: Record) { + return { + rawPath: path, + requestContext: { http: { method } }, + body: body ? JSON.stringify(body) : undefined, + headers: headers ?? {}, + }; +} + +/** Builds a rejection that looks like an AWS SDK service error. */ +function cognitoError(name: string, message = name) { + return Object.assign(new Error(message), { name }); +} + +const TOKENS = { + AccessToken: 'access-tok', + IdToken: 'id-tok', + RefreshToken: 'refresh-tok', + ExpiresIn: 3600, + TokenType: 'Bearer', +}; + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe('POST /login', () => { + it('returns the token set on success', async () => { + mockSend.mockResolvedValue({ AuthenticationResult: TOKENS }); + + const res = await handler(event('/login', 'POST', { email: 'a@b.com', password: 'Pw' })); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ + AccessToken: 'access-tok', + IdToken: 'id-tok', + RefreshToken: 'refresh-tok', + ExpiresIn: 3600, + TokenType: 'Bearer', + }); + }); + + it('uses USER_PASSWORD_AUTH with a lowercased username and no SECRET_HASH', async () => { + mockSend.mockResolvedValue({ AuthenticationResult: TOKENS }); + + await handler(event('/login', 'POST', { email: 'MiXeD@Case.COM', password: 'Pw' })); + + const { input } = mockSend.mock.calls[0][0]; + expect(input.AuthFlow).toBe('USER_PASSWORD_AUTH'); + expect(input.AuthParameters.USERNAME).toBe('mixed@case.com'); + expect(input.AuthParameters.PASSWORD).toBe('Pw'); + expect(input.AuthParameters).not.toHaveProperty('SECRET_HASH'); + }); + + it.each([ + ['NEW_PASSWORD_REQUIRED'], + ['SOFTWARE_TOKEN_MFA'], + ['SMS_MFA'], + ['EMAIL_OTP'], + ['SELECT_MFA_TYPE'], + // The important one: an unmodelled challenge must still resolve. Under the + // old callback-based implementation this hung for the full 30s timeout. + ['SOME_FUTURE_CHALLENGE'], + ])( + 'returns 200 with ChallengeName + Session for %s instead of hanging', + async (challengeName) => { + mockSend.mockResolvedValue({ + ChallengeName: challengeName, + Session: 'sess-1', + ChallengeParameters: { USER_ID_FOR_SRP: 'a@b.com' }, + }); + + const res = await handler(event('/login', 'POST', { email: 'a@b.com', password: 'Pw' })); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.ChallengeName).toBe(challengeName); + expect(body.Session).toBe('sess-1'); + expect(body.AccessToken).toBeUndefined(); + }, + // Short timeout so a regression fails fast rather than stalling the suite. + 2000, + ); + + it('returns 403 for MFA_SETUP but still hands back the Session', async () => { + mockSend.mockResolvedValue({ ChallengeName: 'MFA_SETUP', Session: 'sess-setup' }); + + const res = await handler(event('/login', 'POST', { email: 'a@b.com', password: 'Pw' })); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.ChallengeName).toBe('MFA_SETUP'); + expect(body.Session).toBe('sess-setup'); + }); + + it('returns 500 when Cognito returns neither a result nor a challenge', async () => { + mockSend.mockResolvedValue({}); + + const res = await handler(event('/login', 'POST', { email: 'a@b.com', password: 'Pw' })); + + expect(res.statusCode).toBe(500); + }); + + it('returns 400 on malformed JSON', async () => { + const res = await handler({ + rawPath: '/login', + requestContext: { http: { method: 'POST' } }, + body: '{not json', + headers: {}, + }); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Invalid JSON in request body'); + }); + + it.each([ + ['NotAuthorizedException', 401, 'Invalid email or password'], + ['UserNotFoundException', 401, 'Invalid email or password'], + ['UserNotConfirmedException', 403, 'Email not verified'], + ['PasswordResetRequiredException', 403, 'Password reset required'], + ['TooManyRequestsException', 429, 'Too many attempts, please try again later'], + ['LimitExceededException', 429, 'Too many attempts, please try again later'], + ['TooManyFailedAttemptsException', 429, 'Too many attempts, please try again later'], + ['ForbiddenException', 403, 'Request blocked'], + ['SomethingElseException', 500, 'Authentication failed'], + ])('maps %s to %i', async (name, status, message) => { + mockSend.mockRejectedValue(cognitoError(name)); + + const res = await handler(event('/login', 'POST', { email: 'a@b.com', password: 'Pw' })); + + expect(res.statusCode).toBe(status); + const body = JSON.parse(res.body); + expect(body.message).toBe(message); + expect(body.code).toBe(name); + }); +}); + +describe('POST /respond-challenge', () => { + it.each([ + [{ session: 's', email: 'a@b.com' }], + [{ challengeName: 'NEW_PASSWORD_REQUIRED', email: 'a@b.com' }], + [{ challengeName: 'NEW_PASSWORD_REQUIRED', session: 's' }], + ])('returns 400 when a required top-level field is missing (%p)', async (body) => { + const res = await handler(event('/respond-challenge', 'POST', body)); + + expect(res.statusCode).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('returns 400 and lists supported challenges for an unknown challengeName', async () => { + const res = await handler( + event('/respond-challenge', 'POST', { + challengeName: 'NOPE', + session: 's', + email: 'a@b.com', + }), + ); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).supported).toEqual( + expect.arrayContaining(['NEW_PASSWORD_REQUIRED', 'SOFTWARE_TOKEN_MFA']), + ); + }); + + it('returns 400 when NEW_PASSWORD_REQUIRED omits newPassword', async () => { + const res = await handler( + event('/respond-challenge', 'POST', { + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 's', + email: 'a@b.com', + }), + ); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('newPassword is required'); + }); + + it('returns 400 for a weak newPassword before calling Cognito', async () => { + const res = await handler( + event('/respond-challenge', 'POST', { + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 's', + email: 'a@b.com', + newPassword: 'short', + }), + ); + + expect(res.statusCode).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('completes NEW_PASSWORD_REQUIRED and returns tokens', async () => { + mockSend.mockResolvedValue({ AuthenticationResult: TOKENS }); + + const res = await handler( + event('/respond-challenge', 'POST', { + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'sess-1', + email: 'A@B.com', + newPassword: 'NewPassword123', + name: 'Jane Doe', + }), + ); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).AccessToken).toBe('access-tok'); + + const { input } = mockSend.mock.calls[0][0]; + expect(input.ChallengeName).toBe('NEW_PASSWORD_REQUIRED'); + expect(input.Session).toBe('sess-1'); + expect(input.ChallengeResponses).toEqual({ + USERNAME: 'a@b.com', + NEW_PASSWORD: 'NewPassword123', + 'userAttributes.name': 'Jane Doe', + }); + }); + + it('maps a TOTP code to SOFTWARE_TOKEN_MFA_CODE', async () => { + mockSend.mockResolvedValue({ AuthenticationResult: TOKENS }); + + await handler( + event('/respond-challenge', 'POST', { + challengeName: 'SOFTWARE_TOKEN_MFA', + session: 'sess-1', + email: 'a@b.com', + code: '123456', + }), + ); + + expect(mockSend.mock.calls[0][0].input.ChallengeResponses).toEqual({ + USERNAME: 'a@b.com', + SOFTWARE_TOKEN_MFA_CODE: '123456', + }); + }); + + it('chains: returns the next challenge when Cognito issues another one', async () => { + mockSend.mockResolvedValue({ ChallengeName: 'MFA_SETUP', Session: 'sess-2' }); + + const res = await handler( + event('/respond-challenge', 'POST', { + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'sess-1', + email: 'a@b.com', + newPassword: 'NewPassword123', + }), + ); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).ChallengeName).toBe('MFA_SETUP'); + }); + + it('maps an expired challenge session to 401', async () => { + mockSend.mockRejectedValue(cognitoError('NotAuthorizedException')); + + const res = await handler( + event('/respond-challenge', 'POST', { + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'stale', + email: 'a@b.com', + newPassword: 'NewPassword123', + }), + ); + + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toContain('sign in again'); + }); + + it('maps a wrong MFA code to 400', async () => { + mockSend.mockRejectedValue(cognitoError('CodeMismatchException')); + + const res = await handler( + event('/respond-challenge', 'POST', { + challengeName: 'SOFTWARE_TOKEN_MFA', + session: 's', + email: 'a@b.com', + code: '000000', + }), + ); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Invalid verification code'); + }); +}); + +describe('POST /refresh', () => { + it('returns 400 when refreshToken is missing', async () => { + const res = await handler(event('/refresh', 'POST', {})); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('refreshToken is required'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('uses REFRESH_TOKEN_AUTH and returns new access and ID tokens', async () => { + // Cognito does not re-issue a refresh token on this flow. + mockSend.mockResolvedValue({ + AuthenticationResult: { + AccessToken: 'new-access', + IdToken: 'new-id', + ExpiresIn: 3600, + TokenType: 'Bearer', + }, + }); + + const res = await handler(event('/refresh', 'POST', { refreshToken: 'r-tok' })); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.AccessToken).toBe('new-access'); + expect(body.IdToken).toBe('new-id'); + expect(body.RefreshToken).toBeUndefined(); + + const { input } = mockSend.mock.calls[0][0]; + expect(input.AuthFlow).toBe('REFRESH_TOKEN_AUTH'); + expect(input.AuthParameters).toEqual({ REFRESH_TOKEN: 'r-tok' }); + }); + + it('returns 401 for an expired refresh token', async () => { + mockSend.mockRejectedValue(cognitoError('NotAuthorizedException')); + + const res = await handler(event('/refresh', 'POST', { refreshToken: 'stale' })); + + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toBe('Refresh token is invalid or expired'); + }); + + it('returns 401 when Cognito responds without an AuthenticationResult', async () => { + mockSend.mockResolvedValue({}); + + const res = await handler(event('/refresh', 'POST', { refreshToken: 'r-tok' })); + + expect(res.statusCode).toBe(401); + }); +}); + +describe('GET /me', () => { + it('returns 401 when unauthenticated', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + + const res = await handler(event('/me', 'GET')); + + expect(res.statusCode).toBe(401); + expect(mockExecuteTakeFirst).not.toHaveBeenCalled(); + }); + + it('returns 401 when the token verifies but no branch.users row exists', async () => { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { cognitoSub: 'sub-1', isAdmin: false }, + }); + mockExecuteTakeFirst.mockResolvedValue(undefined); + + const res = await handler(event('/me', 'GET')); + + expect(res.statusCode).toBe(401); + }); + + it('sources isAdmin from the database row, not the auth context', async () => { + // Regression guard: /auth/me is the only place the frontend can learn + // isAdmin, and it must reflect branch.users rather than any token claim. + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { cognitoSub: 'sub-1', isAdmin: false }, + }); + mockExecuteTakeFirst.mockResolvedValue({ + user_id: 7, + cognito_sub: 'sub-1', + email: 'a@b.com', + name: 'Ada', + is_admin: true, + profile_image: null, + }); + + const res = await handler(event('/me', 'GET', undefined, { Authorization: 'Bearer t' })); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ + userId: 7, + cognitoSub: 'sub-1', + email: 'a@b.com', + name: 'Ada', + isAdmin: true, + profileImage: null, + }); + }); + + it('coerces a non-boolean is_admin to false', async () => { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { cognitoSub: 'sub-1', isAdmin: true }, + }); + mockExecuteTakeFirst.mockResolvedValue({ + user_id: 7, + cognito_sub: 'sub-1', + email: 'a@b.com', + name: 'Ada', + is_admin: null, + profile_image: null, + }); + + const res = await handler(event('/me', 'GET')); + + expect(JSON.parse(res.body).isAdmin) .toBe(false); + }); +}); + +describe('POST /register — claim-on-register', () => { + const validBody = { email: 'Ashley@branch.org', password: 'Password123', name: 'Ashley' }; + + it('claims a pending invitation (cognito_sub IS NULL) instead of returning 409', async () => { + mockExecuteTakeFirst.mockResolvedValue({ + user_id: 1, + email: 'ashley@branch.org', + cognito_sub: null, + is_admin: true, + }); + mockSend.mockResolvedValue({ UserSub: 'new-sub' }); + mockExecute.mockResolvedValue(undefined); + + const res = await handler(event('/register', 'POST', validBody)); + + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).claimed).toBe(true); + // Updated, not inserted — the row keeps its user_id and is_admin. + expect(mockSet).toHaveBeenCalledWith({ cognito_sub: 'new-sub', name: 'Ashley' }); + expect(mockValues).not.toHaveBeenCalled(); + }); + + it('never writes is_admin when claiming, so a public endpoint cannot grant admin', async () => { + mockExecuteTakeFirst.mockResolvedValue({ + user_id: 1, + email: 'ashley@branch.org', + cognito_sub: null, + is_admin: true, + }); + mockSend.mockResolvedValue({ UserSub: 'new-sub' }); + mockExecute.mockResolvedValue(undefined); + + await handler(event('/register', 'POST', validBody)); + + expect(mockSet.mock.calls[0][0]).not.toHaveProperty('is_admin'); + }); + + it('returns 409 without calling Cognito when the row is already claimed', async () => { + mockExecuteTakeFirst.mockResolvedValue({ + user_id: 1, + email: 'ashley@branch.org', + cognito_sub: 'existing-sub', + }); + + const res = await handler(event('/register', 'POST', validBody)); + + expect(res.statusCode).toBe(409); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('inserts a new row when no invitation exists', async () => { + mockExecuteTakeFirst.mockResolvedValue(undefined); + mockSend.mockResolvedValue({ UserSub: 'new-sub' }); + mockExecute.mockResolvedValue(undefined); + + const res = await handler(event('/register', 'POST', validBody)); + + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).claimed).toBeUndefined(); + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ cognito_sub: 'new-sub', is_admin: false }), + ); + }); + + it('rolls back the Cognito user when the database write fails', async () => { + mockExecuteTakeFirst.mockResolvedValue(undefined); + mockSend + .mockResolvedValueOnce({ UserSub: 'new-sub' }) // SignUp + .mockResolvedValueOnce({}); // AdminDeleteUser + mockExecute.mockRejectedValue(new Error('db down')); + + const res = await handler(event('/register', 'POST', validBody)); + + expect(res.statusCode).toBe(500); + expect(mockSend).toHaveBeenCalledTimes(2); + expect(mockSend.mock.calls[1][0].input).toEqual( + expect.objectContaining({ Username: 'ashley@branch.org' }), + ); + }); + + it('links an existing Cognito user when the DB row is an unclaimed invitation', async () => { + mockExecuteTakeFirst.mockResolvedValue({ + user_id: 1, + email: 'ashley@branch.org', + cognito_sub: null, + }); + mockSend + .mockRejectedValueOnce(cognitoError('UsernameExistsException')) // SignUp + .mockResolvedValueOnce({ + UserStatus: 'CONFIRMED', + UserAttributes: [{ Name: 'sub', Value: 'orphan-sub' }], + }); // AdminGetUser + mockExecute.mockResolvedValue(undefined); + + const res = await handler(event('/register', 'POST', validBody)); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).claimed).toBe(true); + expect(mockSet).toHaveBeenCalledWith({ cognito_sub: 'orphan-sub' }); + }); + + it('falls back to 409 when the orphan link cannot be completed', async () => { + mockExecuteTakeFirst.mockResolvedValue({ + user_id: 1, + email: 'ashley@branch.org', + cognito_sub: null, + }); + mockSend + .mockRejectedValueOnce(cognitoError('UsernameExistsException')) + // No AWS credentials locally, so AdminGetUser is SigV4-signed and fails. + .mockRejectedValueOnce(cognitoError('AccessDeniedException')); + + const res = await handler(event('/register', 'POST', validBody)); + + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).code).toBe('COGNITO_USER_EXISTS'); + }); + + it('rejects a weak password before touching the database', async () => { + const res = await handler( + event('/register', 'POST', { ...validBody, password: 'nouppercase1' }), + ); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('uppercase'); + expect(mockExecuteTakeFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index 25282d79..7a12f525 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -209,7 +209,7 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' }, body: JSON.stringify(body) }; diff --git a/apps/backend/lambdas/projects/auth.ts b/apps/backend/lambdas/projects/auth.ts index 054ec087..ea9f5879 100644 --- a/apps/backend/lambdas/projects/auth.ts +++ b/apps/backend/lambdas/projects/auth.ts @@ -80,3 +80,25 @@ export async function canCreateProject(userId: number): Promise { return false; } } + +/** + * Admin-only, deliberately stricter than canEditProject. + * + * Deleting a project cascades to project_memberships, project_donations, + * expenditures and reports (ON DELETE CASCADE in db/db_setup.sql), destroying + * financial history. A PI may edit a project but must not be able to erase it. + */ +export async function canDeleteProject(userId: number): Promise { + try { + const user = await db + .selectFrom('branch.users') + .where('user_id', '=', userId) + .select('is_admin') + .executeTakeFirst(); + + return user?.is_admin === true; + } catch (error) { + console.error('Error checking delete access:', error); + return false; + } +} diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index f41b6fdd..ab5d0d9a 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -5,6 +5,7 @@ import { authenticateRequest, canAccessProject, canCreateProject, + canDeleteProject, canEditProject, } from './auth'; @@ -263,12 +264,17 @@ export const handler = async (event: any): Promise => { // DELETE /projects/{id} if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') { - // TODO: requireAuth needs to be added here once ticket #241 is completed - const id = rawPath.split('/')[1]; if (!id) return json(400, { message: 'id is required' }); if (!/^\d+$/.test(id)) return json(400, { message: 'id must be a valid number' }); + // The gate at the top of this handler establishes authentication but not + // authorization; this route previously had neither check, so any + // authenticated user could delete any project. + if (!(await canDeleteProject(user.userId!))) { + return json(403, { message: 'Admin access required' }); + } + const deleted = await db.deleteFrom('branch.projects').where('project_id', '=', Number(id)).execute(); if (!deleted[0] || deleted[0].numDeletedRows === 0n) { return json(404, { message: 'Project not found' }); @@ -389,7 +395,7 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' }, body: JSON.stringify(body) }; diff --git a/apps/backend/lambdas/projects/test/delete-authz.unit.test.ts b/apps/backend/lambdas/projects/test/delete-authz.unit.test.ts new file mode 100644 index 00000000..f92b0383 --- /dev/null +++ b/apps/backend/lambdas/projects/test/delete-authz.unit.test.ts @@ -0,0 +1,109 @@ +/** + * Regression tests for the unguarded DELETE /projects/{id}. + * + * The route sat behind a stale "TODO: requireAuth needs to be added here once + * ticket #241 is completed" long after #241 merged, so the handler's global gate + * established authentication but nothing checked authorization: any authenticated + * user — including a Staff member of an unrelated project — could delete any + * project, cascading away its memberships, donations, expenditures and reports. + */ +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; + +const mockAuthenticateRequest = jest.fn(); +const mockCanDeleteProject = jest.fn(); + +jest.mock('../auth', () => ({ + authenticateRequest: (...a: unknown[]) => mockAuthenticateRequest(...a), + canDeleteProject: (...a: unknown[]) => mockCanDeleteProject(...a), + canAccessProject: jest.fn(), + canCreateProject: jest.fn(), + canEditProject: jest.fn(), +})); + +const mockDeleteExecute = jest.fn(); +jest.mock('../db', () => ({ + __esModule: true, + default: { + deleteFrom: () => ({ + where: () => ({ execute: (...a: unknown[]) => mockDeleteExecute(...a) }), + }), + }, +})); + +import { handler } from '../handler'; + +function deleteEvent(id: string | number) { + return { + rawPath: `/${id}`, + requestContext: { http: { method: 'DELETE' } }, + headers: { Authorization: 'Bearer fake-token' }, + } as any; +} + +const adminContext = { + isAuthenticated: true, + user: { cognitoSub: 'admin-sub', userId: 1, email: 'admin@branch.org', isAdmin: true }, +}; + +const staffContext = { + isAuthenticated: true, + user: { cognitoSub: 'staff-sub', userId: 5, email: 'staff@branch.org', isAdmin: false }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockDeleteExecute.mockResolvedValue([{ numDeletedRows: 1n }]); +}); + +describe('DELETE /projects/{id}', () => { + test('401 when unauthenticated, and nothing is deleted', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + + const res = await handler(deleteEvent(4)); + + expect(res.statusCode).toBe(401); + expect(mockDeleteExecute).not.toHaveBeenCalled(); + }); + + test('403 for an authenticated non-admin, and nothing is deleted', async () => { + mockAuthenticateRequest.mockResolvedValue(staffContext); + mockCanDeleteProject.mockResolvedValue(false); + + const res = await handler(deleteEvent(4)); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('Admin access required'); + expect(mockDeleteExecute).not.toHaveBeenCalled(); + }); + + test('200 for an admin', async () => { + mockAuthenticateRequest.mockResolvedValue(adminContext); + mockCanDeleteProject.mockResolvedValue(true); + + const res = await handler(deleteEvent(4)); + + expect(res.statusCode).toBe(200); + expect(mockCanDeleteProject).toHaveBeenCalledWith(1); + expect(mockDeleteExecute).toHaveBeenCalled(); + }); + + test('404 when an admin targets a project that does not exist', async () => { + mockAuthenticateRequest.mockResolvedValue(adminContext); + mockCanDeleteProject.mockResolvedValue(true); + mockDeleteExecute.mockResolvedValue([{ numDeletedRows: 0n }]); + + const res = await handler(deleteEvent(999)); + + expect(res.statusCode).toBe(404); + }); + + test('400 for a non-numeric id, checked before any authorization work', async () => { + mockAuthenticateRequest.mockResolvedValue(adminContext); + + const res = await handler(deleteEvent('abc')); + + expect(res.statusCode).toBe(400); + expect(mockCanDeleteProject).not.toHaveBeenCalled(); + expect(mockDeleteExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 7b01f65c..ec67d335 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -279,7 +279,7 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' }, body: JSON.stringify(body) }; diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index e82348e0..1e5ff6c9 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -145,7 +145,17 @@ export const handler = async (event: any): Promise => { const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); - if (isAdminResult.value != null) updates.is_admin = isAdminResult.value; + 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 }); @@ -265,7 +275,7 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' }, body: JSON.stringify(body) }; diff --git a/apps/backend/lambdas/users/test/user.privilege-escalation.test.ts b/apps/backend/lambdas/users/test/user.privilege-escalation.test.ts new file mode 100644 index 00000000..52eb39dd --- /dev/null +++ b/apps/backend/lambdas/users/test/user.privilege-escalation.test.ts @@ -0,0 +1,147 @@ +/** + * Regression tests for the PATCH /users/{userId} self-escalation hole. + * + * The route is ADMIN_OR_SELF, which intentionally lets a non-admin edit their own + * row. Before the fix it also wrote body.isAdmin, so any user could PATCH + * { isAdmin: true } to their own userId and become an admin. + */ +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; + +jest.mock('../db'); +jest.mock('../auth'); + +import { handler } from '../handler'; +import db from '../db'; +import { authenticateRequest, checkAuthorization } from '../auth'; + +const mockDb = db as any; +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction< + typeof authenticateRequest +>; +const mockCheckAuthorization = checkAuthorization as jest.MockedFunction< + typeof checkAuthorization +>; + +// Mirrors the real checkAuthorization for the levels this route uses. +mockCheckAuthorization.mockImplementation((authContext, requiredAccess, resourceUserId?) => { + if (!authContext.isAuthenticated || !authContext.user) { + return { allowed: false, reason: 'Authentication required' }; + } + if (requiredAccess === 'ADMIN_OR_SELF') { + const allowed = + (authContext.user.isAdmin ?? false) || authContext.user.userId === Number(resourceUserId); + return { allowed, reason: allowed ? undefined : 'Admin access or resource ownership required' }; + } + return { allowed: true }; +}); + +const mockSet = jest.fn(); + +function patchEvent(userId: string | number, body: unknown) { + return { + rawPath: `/${userId}`, + requestContext: { http: { method: 'PATCH' } }, + body: JSON.stringify(body), + }; +} + +function mockDbForPatch() { + mockDb.selectFrom.mockReturnValue({ + where: jest.fn().mockReturnValue({ + selectAll: jest.fn().mockReturnValue({ + executeTakeFirst: (jest.fn() as any).mockResolvedValue({ + user_id: 2, + name: 'Regular User', + email: 'user@example.com', + is_admin: false, + profile_image: null, + }), + }), + }), + }); + + mockSet.mockReturnValue({ + where: jest.fn().mockReturnValue({ + execute: (jest.fn() as any).mockResolvedValue(undefined), + }), + }); + mockDb.updateTable.mockReturnValue({ set: mockSet }); +} + +/** Non-admin, userId 2 — so /2 is "self". */ +function mockSelfAuth() { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { cognitoSub: 'user-123', userId: 2, email: 'user@example.com', isAdmin: false }, + }); +} + +function mockAdminAuth() { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { cognitoSub: 'admin-123', userId: 1, email: 'admin@example.com', isAdmin: true }, + }); +} + +describe('PATCH /users/{userId} — isAdmin is a privilege grant', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDbForPatch(); + }); + + test('403: a non-admin cannot promote themselves to admin', async () => { + mockSelfAuth(); + + const res = await handler(patchEvent(2, { isAdmin: true })); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('Only an admin can change isAdmin'); + expect(mockSet).not.toHaveBeenCalled(); + }); + + test('403: a non-admin cannot set isAdmin false either — the field is admin-only', async () => { + mockSelfAuth(); + + const res = await handler(patchEvent(2, { isAdmin: false })); + + expect(res.statusCode).toBe(403); + expect(mockSet).not.toHaveBeenCalled(); + }); + + test('403: isAdmin is rejected even when bundled with legitimate profile fields', async () => { + mockSelfAuth(); + + const res = await handler(patchEvent(2, { name: 'New Name', isAdmin: true })); + + expect(res.statusCode).toBe(403); + // Critically, the whole update is refused — no partial write. + expect(mockSet).not.toHaveBeenCalled(); + }); + + test('200: a non-admin can still edit their own profile fields', async () => { + mockSelfAuth(); + + const res = await handler(patchEvent(2, { name: 'New Name' })); + + expect(res.statusCode).toBe(200); + expect(mockSet).toHaveBeenCalledWith({ name: 'New Name' }); + }); + + test('200: an admin can change isAdmin', async () => { + mockAdminAuth(); + + const res = await handler(patchEvent(2, { isAdmin: true })); + + expect(res.statusCode).toBe(200); + expect(mockSet).toHaveBeenCalledWith({ is_admin: true }); + }); + + test('200: an admin can demote a user', async () => { + mockAdminAuth(); + + const res = await handler(patchEvent(2, { isAdmin: false })); + + expect(res.statusCode).toBe(200); + expect(mockSet).toHaveBeenCalledWith({ is_admin: false }); + }); +}); diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index a361424b..9cc2f5bc 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -25,12 +25,18 @@ src/ layout.tsx # root layout (Server Component) providers.tsx # 'use client' — ChakraProvider + AuthProvider globals.css # Tailwind v4 import + @theme custom tokens - page.tsx login/ forgot-password/ reset-password/ donors/ donations/ expenses/ + page.tsx # routes by session; also absorbs the CloudFront SPA fallback + dashboard/ login/ forgot-password/ reset-password/ donors/ donations/ expenses/ + projects/page.tsx # projects index projects/[id]/page.tsx # dynamic route - components/ # shared UI (Navbar, Header, ExpensesTable, Pagination, modals, form fields) - context/AuthContext.tsx # useAuth() — login/register/verify/logout/reset; tokens in localStorage + components/ # shared UI (AuthGate, Navbar, Header, tables, modals, form fields) + context/AuthContext.tsx # useAuth() — session, login/challenge/logout/reset + hooks/useApi.ts # useApi() — authenticated HTTP, the one components should use hooks/useQueryParams.ts # sync filter state <-> URL query string - lib/api.ts # apiFetch() — the single HTTP client + lib/api.ts # apiFetch() + ApiError — raw HTTP, no session awareness + lib/authTokens.ts # the ONLY module that touches token storage + lib/authClient.ts # authedFetch + single-flight refresh + session-expiry events + lib/routes.ts # route access policy (protected-by-default) test/ # jest + RTL mirror of src/ (custom render in test/utils.tsx) ``` @@ -40,11 +46,27 @@ test/ # jest + RTL mirror of src/ (custom render in test/u No React Query / SWR / Redux. Pattern: `useState` + `useEffect` + `apiFetch`, local component state. -`src/lib/api.ts` — `apiFetch(path, { token?, ... })`. Routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001), or to `NEXT_PUBLIC_API_BASE_URL` if set. Injects `Authorization: Bearer `. Throws on non-2xx. In production `NEXT_PUBLIC_API_BASE_URL` (API Gateway) is set at build so every call goes there with its full prefixed path; the localhost port map is the dev fallback. (Static export has no server, so there are no `next.config` rewrites.) New backend calls go through `apiFetch` — don't hand-roll `fetch`. +**Components should call `useApi()`, not `apiFetch`.** `useApi()` returns a stable `{ get, post, patch, put, del }` over `authedFetch`, which attaches the current access token, refreshes it when it is expiring or a 401 comes back, and ends the session cleanly when it cannot. Never read a token out of storage and thread it through props — that pattern is what previously sent unauthenticated requests, because `localStorage.getItem(...) ?? ''` yields an empty token and `apiFetch` then silently omits the header. + +`src/lib/api.ts` — `apiFetch(path, { token?, ... })`, the raw client underneath. Routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001), or to `NEXT_PUBLIC_API_BASE_URL` if set. Throws `ApiError` (carrying `status` and `body`) on non-2xx. In production `NEXT_PUBLIC_API_BASE_URL` (API Gateway) is set at build so every call goes there with its full prefixed path; the localhost port map is the dev fallback. (Static export has no server, so there are no `next.config` rewrites.) Use `apiFetch` directly only for genuinely unauthenticated endpoints (`/auth/login`, `/auth/refresh`, `/auth/forgot-password`). + +Import direction is one-way and must stay that way: `api.ts` ← `authClient.ts` ← `AuthContext.tsx` / `hooks/useApi.ts`. ## Auth -`src/context/AuthContext.tsx` — custom JWT, **no Cognito SDK on the client**. Login POSTs `/auth/login`, stores `branch_access_token` / `branch_id_token` / `branch_refresh_token` in localStorage, decodes the ID token payload (base64) for user claims. `useAuth()` exposes `login, register, verifyEmail, logout, getAccessToken, forgotPassword, resetPassword`. Pass `getAccessToken()` result as the `token` option to `apiFetch` for protected calls. Navbar filters items by user role. +**No Cognito SDK on the client.** The browser only ever talks to the backend's `/auth/*` routes; the auth lambda does the Cognito work. + +**Session.** Tokens (`branch_access_token` / `branch_id_token` / `branch_refresh_token`) live in localStorage, owned exclusively by `src/lib/authTokens.ts`. `grep -rn "branch_access_token" src/` must only ever match that file. + +**Identity comes from `GET /auth/me`, never from decoding a token.** `is_admin` lives only in Postgres and there is no pre-token-generation trigger, so it is not a JWT claim; a Cognito *access* token carries neither `email` nor `name` either. `AuthProvider` calls `/auth/me` on mount (skipping the call entirely when no tokens are stored) and exposes `user`, `isAuthenticated`, `isAdmin`, `isLoading`. Do not add a "fall back to decoding the ID token" path. + +**Refresh.** Access tokens last one hour. `AuthProvider` schedules a refresh ~2 minutes before expiry, and `authedFetch` refreshes reactively on a 401. Refresh is single-flight, so a burst of concurrent 401s produces one `POST /auth/refresh`. Cognito does not re-issue a refresh token, so `saveTokens` leaves the stored one alone when the response omits it. + +**Guarding.** `src/app/components/AuthGate.tsx`, mounted once in `providers.tsx`, is the app's only route guard — static export means `middleware.ts` would never run. `src/lib/routes.ts` classifies routes and is **protected-by-default**: a new page under `src/app/` is guarded without opting in. Public routes are `/login`, `/forgot-password`, `/reset-password` (authenticated users get bounced off them); `/expenses`, `/reports` and `/accounts` additionally require `isAdmin`. Always compare paths through `normalizePath` — `trailingSlash: true` means production sees `/login/` where dev and tests see `/login`. + +**Challenges.** `login()` returns `{ status: 'authenticated' }` or `{ status: 'challenge', ... }`. `NEW_PASSWORD_REQUIRED` is handled by the login page; the other challenge names are plumbed through `respondToChallenge` and become reachable if MFA is switched on in `infrastructure/aws/cognito.tf`, needing only a UI step. + +**No self-serve signup.** The backend still serves `/auth/register`, `/auth/verify-email` and `/auth/resend-code`, but the frontend deliberately does not expose them — see the comment in `AuthContext.tsx`. Onboarding is admin-invite. ## Styling diff --git a/apps/frontend/src/app/components/AddExpenseModal.tsx b/apps/frontend/src/app/components/AddExpenseModal.tsx index 87a1855c..883d97f6 100644 --- a/apps/frontend/src/app/components/AddExpenseModal.tsx +++ b/apps/frontend/src/app/components/AddExpenseModal.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; import DropdownSelector from './DropdownSelector'; -import { apiFetch } from '@/lib/api'; +import { useApi } from '@/hooks/useApi'; import FileUpload from './FileUpload'; import { FiDollarSign } from 'react-icons/fi'; @@ -16,7 +16,6 @@ interface AddExpenseModalProps { open: boolean; onClose: () => void; onSuccess: () => void; - token: string; categories: string[]; projects: Project[]; } @@ -25,10 +24,11 @@ export default function AddExpenseModal({ open, onClose, onSuccess, - token, categories, projects, }: AddExpenseModalProps) { + const api = useApi(); + const [newDate, setNewDate] = useState(''); const [newType, setNewType] = useState(''); const [newDescription, setNewDescription] = useState(''); @@ -89,16 +89,12 @@ export default function AddExpenseModal({ } try { - await apiFetch('/expenditures', { - method: 'POST', - token, - body: JSON.stringify({ - projectID: selectedProject.project_id, - amount: Number(newAmount), - category: newType, - description: newDescription, - spentOn: newDate, - }), + await api.post('/expenditures', { + projectID: selectedProject.project_id, + amount: Number(newAmount), + category: newType, + description: newDescription, + spentOn: newDate, }); resetForm(); diff --git a/apps/frontend/src/app/components/AuthGate.tsx b/apps/frontend/src/app/components/AuthGate.tsx new file mode 100644 index 00000000..e1c9a917 --- /dev/null +++ b/apps/frontend/src/app/components/AuthGate.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; +import { + LOGIN_PATH, + POST_LOGIN_PATH, + classifyRoute, + requiresAdmin, +} from '@/lib/routes'; +import FullPageSpinner from './FullPageSpinner'; + +/** + * The app's only route guard. + * + * Mounted once in `providers.tsx` rather than per page. A `useRequireAuth()` + * hook or a per-page `` wrapper would both have to be remembered + * on every new page — the same class of omission that left the entire app + * unguarded in the first place — and neither can stop protected UI from painting + * for a frame. `middleware.ts` is not an option: `output: 'export'` means there + * is no server to run it. + * + * Two mechanisms, on purpose: + * 1. an effect that navigates, and + * 2. early returns that keep the protected tree from mounting at all, so + * protected pages never fire their data-fetch effects while signed out. + */ +export default function AuthGate({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isAdmin, isLoading } = useAuth(); + const router = useRouter(); + const pathname = usePathname() ?? '/'; + const access = classifyRoute(pathname); + + useEffect(() => { + // No redirect before the session has been resolved, or a returning user + // would be bounced to /login during their own bootstrap. + if (isLoading) return; + + if (access === 'protected' && !isAuthenticated) { + const search = + typeof window !== 'undefined' ? window.location.search : ''; + const next = encodeURIComponent(`${pathname}${search}`); + router.replace(`${LOGIN_PATH}?next=${next}`); + return; + } + + if (access === 'public' && isAuthenticated) { + router.replace(POST_LOGIN_PATH); + } + }, [isLoading, isAuthenticated, access, pathname, router]); + + if (isLoading) return ; + + // Render suppression — the redirect above is asynchronous, and without these + // the protected page would mount and start fetching in the meantime. + if (access === 'protected' && !isAuthenticated) return ; + if (access === 'public' && isAuthenticated) return ; + + // Non-admins get an explanation in place rather than a redirect: bouncing them + // can loop if the admin flag changes mid-session, and "page not found" would + // be a lie. This is also what makes the Navbar's role filtering more than + // cosmetic — hiding a link never stopped anyone typing the URL. + if (access === 'protected' && requiresAdmin(pathname) && !isAdmin) { + return ; + } + + return <>{children}; +} + +function NoAccessPanel() { + return ( +
+

+ You don't have access to this page +

+

+ This section is limited to administrators. If you think you should have + access, ask an admin to update your account. +

+ + Back to dashboard + +
+ ); +} diff --git a/apps/frontend/src/app/components/FullPageSpinner.tsx b/apps/frontend/src/app/components/FullPageSpinner.tsx new file mode 100644 index 00000000..e9663b5d --- /dev/null +++ b/apps/frontend/src/app/components/FullPageSpinner.tsx @@ -0,0 +1,43 @@ +'use client'; + +/** + * Neutral full-viewport placeholder shown while the session resolves or a + * redirect is in flight. + * + * Deliberately reveals nothing about the app shell — the whole point of the + * guard is that unauthenticated visitors never see it — and deliberately has no + * component-library dependency, because it renders from AuthGate and the root + * page, above anything that could be relied on to be mounted. + */ +export default function FullPageSpinner({ + label = 'Loading…', +}: { + label?: string; +}) { + return ( +
+ +
+
+ ); +} diff --git a/apps/frontend/src/app/components/Header.tsx b/apps/frontend/src/app/components/Header.tsx index 66476a59..54ead2b6 100644 --- a/apps/frontend/src/app/components/Header.tsx +++ b/apps/frontend/src/app/components/Header.tsx @@ -1,16 +1,30 @@ +'use client'; + import React from 'react'; import Image from "next/image"; import { assetPath } from "@/lib/asset"; +import { useAuth } from "@/context/AuthContext"; interface HeaderProps { text?: string; icon?: React.ReactNode; } -const Header: React.FC = ({ - text = "BRANCH Accounting Platform", - icon +function initialsOf(name: string | undefined | null): string { + const parts = (name ?? '').trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); +} + +const Header: React.FC = ({ + text = "BRANCH Accounting Platform", + icon }) => { + // Identity comes from GET /auth/me, not from decoding a token — isAdmin in + // particular exists only in Postgres and is not a JWT claim. + const { user, isAdmin } = useAuth(); + return (
= ({
{text}
- + {/* Flexible Icon Section */} -
- {icon || ( - // Default Profile Icon matching Figma +
+ {icon ?? (user ? ( + <> +
+ {user.name} + {user.email} +
+ {isAdmin && ( + + Admin + + )} + + + ) : ( + // Signed out: keep the neutral placeholder rather than implying a user.
- Profile Icon + Profile Icon
- )} + ))}
); }; -export default Header; \ No newline at end of file +export default Header; diff --git a/apps/frontend/src/app/components/Navbar.tsx b/apps/frontend/src/app/components/Navbar.tsx index 7a7546c1..b54fd96d 100644 --- a/apps/frontend/src/app/components/Navbar.tsx +++ b/apps/frontend/src/app/components/Navbar.tsx @@ -6,14 +6,19 @@ import { usePathname, useRouter } from "next/navigation"; import { PT_Sans } from "next/font/google"; import { useAuth } from "@/context/AuthContext"; import { assetPath } from "@/lib/asset"; +import { normalizePath } from "@/lib/routes"; const ptSans = PT_Sans({ subsets: ["latin"], weight: ["400", "700"] }); // ─── Types & Definitions ────────────────────────────────────────────────────── export type UserRole = "admin" | "standard" | "limited"; -interface NavItem { label: string; href: string; roles?: UserRole[]; } +interface NavItem { label: string; href?: string; action?: "logout"; roles?: UserRole[]; } +// Every href here must resolve to a real route. "Profile" was removed because +// no /profile page exists, and "Log Out" is an action rather than a route — +// keying the special case on `action` means a future /logout page couldn't +// silently turn the button back into a dead link. const NAV_ITEMS: NavItem[] = [ { label: "Dashboard", href: "/dashboard" }, { label: "Projects", href: "/projects" }, @@ -22,8 +27,7 @@ const NAV_ITEMS: NavItem[] = [ { label: "Expenses", href: "/expenses", roles: ["admin"] }, { label: "Reports", href: "/reports", roles: ["admin"] }, { label: "Accounts", href: "/accounts", roles: ["admin"] }, - { label: "Profile", href: "/profile" }, - { label: "Log Out", href: "/logout" }, + { label: "Log Out", action: "logout" }, ]; const COLORS = { @@ -33,20 +37,36 @@ const COLORS = { hoverBg: "rgba(255, 255, 255, 0.2)", }; -export const NavBar: React.FC<{ role?: UserRole; activePath?: string }> = ({ - role = "admin", +/** + * `roleOverride` exists for tests only — it is named that way so nobody mistakes + * it for the source of truth again. The role comes from the session, which comes + * from GET /auth/me; it used to default to "admin", which made the role-based + * filtering below purely decorative. Hiding a link was never a security control + * anyway — AuthGate enforces admin routes. + */ +export const NavBar: React.FC<{ roleOverride?: UserRole; activePath?: string }> = ({ + roleOverride, activePath }) => { - const pathname = usePathname?.() ?? "/dashboard"; - const currentPath = activePath ?? pathname; + const pathname = usePathname?.() ?? "/"; + const currentPath = normalizePath(activePath ?? pathname); const router = useRouter(); - const { logout } = useAuth(); + const { logout, isAdmin } = useAuth(); const [hoveredIndex, setHoveredIndex] = useState(null); const [loggingOut, setLoggingOut] = useState(false); + const role: UserRole = roleOverride ?? (isAdmin ? "admin" : "standard"); const visibleItems = NAV_ITEMS.filter(item => !item.roles || item.roles.includes(role)); - const isActive = (href: string) => currentPath === href || (href !== "/" && currentPath.startsWith(href)); + + // Compare normalized paths: trailingSlash: true means production sees + // "/expenses/" where dev and tests see "/expenses". The boundary check stops + // "/projects" from highlighting for "/projects-archive". + const isActive = (href: string) => { + const target = normalizePath(href); + if (currentPath === target) return true; + return target !== "/" && currentPath.startsWith(`${target}/`); + }; const handleLogout = async () => { if (loggingOut) return; @@ -54,7 +74,8 @@ export const NavBar: React.FC<{ role?: UserRole; activePath?: string }> = ({ try { await logout(); } finally { - router.push("/login"); + // replace, not push: Back should not return to an authenticated page. + router.replace("/login"); } }; @@ -113,9 +134,9 @@ export const NavBar: React.FC<{ role?: UserRole; activePath?: string }> = ({ }}>
    {visibleItems.map((item, index) => { - const active = isActive(item.href); + const isLogout = item.action === "logout"; + const active = item.href ? isActive(item.href) : false; const isHovered = hoveredIndex === index; - const isLogout = item.href === "/logout"; const sharedStyle: React.CSSProperties = { display: "block", @@ -137,11 +158,11 @@ export const NavBar: React.FC<{ role?: UserRole; activePath?: string }> = ({ return (
  • setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(null)} > - {isLogout ? ( + {isLogout || !item.href ? ( -
- ); -} \ No newline at end of file diff --git a/apps/frontend/src/app/components/ResetLinkSet.tsx b/apps/frontend/src/app/components/ResetLinkSet.tsx deleted file mode 100644 index fecb44a5..00000000 --- a/apps/frontend/src/app/components/ResetLinkSet.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import React from 'react'; -import Link from 'next/link'; -import { Button } from '@chakra-ui/react'; - -export default function ResetLinkSet() { - return ( -
-
-

- Reset Link Sent! -

-
- We sent a reset link to name@gmail.com with a link to reset your password. -
-
-
- {/* TODO: connect up to backend */} - - {/* TODO: Update href when login page route is finalized */} - - Back to login - -
-
- ); -} diff --git a/apps/frontend/src/app/components/ResetPasswordConfirmation.tsx b/apps/frontend/src/app/components/ResetPasswordConfirmation.tsx deleted file mode 100644 index fc5682e5..00000000 --- a/apps/frontend/src/app/components/ResetPasswordConfirmation.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import React from 'react'; -import { Button } from '@chakra-ui/react'; -import { useRouter } from 'next/navigation'; - - -export default function ResetPasswordConfirmation() { - const router = useRouter(); - - return ( -
-
-

Password Changed

-
Your password has been successfully changed!
-
- {/*TODO: figure out how to connect the button to login page */} - -
- ); -} \ No newline at end of file diff --git a/apps/frontend/src/app/components/ResetPasswordForm.tsx b/apps/frontend/src/app/components/ResetPasswordForm.tsx deleted file mode 100644 index 10b73f60..00000000 --- a/apps/frontend/src/app/components/ResetPasswordForm.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import React from 'react'; -import TextInputField from './TextInputField'; -import Link from 'next/link'; -import { Button } from '@chakra-ui/react'; - -export default function ResetPasswordForm() { - return ( -
-
-

- Forgot your Password? -

-
- Please enter the email address you'd like your password reset information sent to -
-
-
- - {/* TODO: connect up to backend */} - - {/* TODO: Update href when login page route is finalized */} - - Back to login - -
-
- ); -} diff --git a/apps/frontend/src/app/components/SetPasswordForm.tsx b/apps/frontend/src/app/components/SetPasswordForm.tsx new file mode 100644 index 00000000..e505cadf --- /dev/null +++ b/apps/frontend/src/app/components/SetPasswordForm.tsx @@ -0,0 +1,103 @@ +'use client'; + +import React, { useState } from 'react'; +import TextInputField from './TextInputField'; +import { Button } from '@chakra-ui/react'; + +/** + * Reusable "new password + confirm" pair. + * + * Used by both the reset-password flow and the NEW_PASSWORD_REQUIRED step of + * login, so the validation rules live in exactly one place. Replaces the old + * NewPasswordForm, which was uncontrolled, unvalidated and imported nowhere. + */ + +export const PASSWORD_RULE = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/; + +export const PASSWORD_RULE_MESSAGE = + 'Password must be at least 8 characters with uppercase, lowercase, number, and symbol'; + +interface SetPasswordFormProps { + onSubmit: (newPassword: string) => Promise | void; + heading?: string; + submitLabel?: string; + /** Server-side error surfaced by the caller. */ + error?: string | null; + isLoading?: boolean; +} + +export default function SetPasswordForm({ + onSubmit, + heading = 'Reset Password', + submitLabel = 'Reset Password', + error = null, + isLoading = false, +}: SetPasswordFormProps) { + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [newPasswordError, setNewPasswordError] = useState(''); + const [confirmPasswordError, setConfirmPasswordError] = useState(''); + + function validate(): boolean { + let valid = true; + + if (!newPassword || !PASSWORD_RULE.test(newPassword)) { + setNewPasswordError(PASSWORD_RULE_MESSAGE); + valid = false; + } else { + setNewPasswordError(''); + } + + if (newPassword !== confirmPassword) { + setConfirmPasswordError('Password does not match'); + valid = false; + } else { + setConfirmPasswordError(''); + } + + return valid; + } + + async function handleSubmit() { + if (!validate()) return; + await onSubmit(newPassword); + } + + return ( +
+

+ {heading} +

+ {error && ( +

+ {error} +

+ )} +
+ setNewPassword(value)} + /> + setConfirmPassword(value)} + /> +
+ +
+ ); +} diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx new file mode 100644 index 00000000..f5dfd992 --- /dev/null +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import NavBar from '../components/Navbar'; +import Header from '../components/Header'; +import ProjectCard from '../components/ProjectCard'; +import { useApi } from '@/hooks/useApi'; +import { useAuth } from '@/context/AuthContext'; + +/** + * Landing page for a signed-in user, and the target the login flow redirects to. + * + * The Navbar has always linked here; the route simply never existed. + */ + +interface ProjectRow { + project_id: number; + name: string; + total_budget: number | string | null; +} + +export default function DashboardPage() { + const api = useApi(); + const { user } = useAuth(); + const [projects, setProjects] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const load = useCallback(async () => { + try { + setError(null); + setProjects(await api.get('/projects')); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not load projects'); + } finally { + setIsLoading(false); + } + }, [api]); + + useEffect(() => { + void load(); + }, [load]); + + const firstName = user?.name?.split(' ')[0]; + + return ( +
+ +
+
+
+

+ {firstName ? `Welcome back, ${firstName}` : 'Dashboard'} +

+ + {isLoading &&

Loading projects…

} + {error &&

{error}

} + {!isLoading && !error && projects.length === 0 && ( +

You are not a member of any projects yet.

+ )} + +
+ {projects.map((project) => ( + + + + ))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index 712d399c..33c60627 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -81,7 +81,7 @@ export default function DonationsPage() { return (
- +

Donations

diff --git a/apps/frontend/src/app/donors/page.tsx b/apps/frontend/src/app/donors/page.tsx index f31b3037..152d00ab 100644 --- a/apps/frontend/src/app/donors/page.tsx +++ b/apps/frontend/src/app/donors/page.tsx @@ -78,7 +78,7 @@ export default function DonorsPage() { return (
- +

Donors

diff --git a/apps/frontend/src/app/expenses/page.tsx b/apps/frontend/src/app/expenses/page.tsx index 67d6758c..7c7e884d 100644 --- a/apps/frontend/src/app/expenses/page.tsx +++ b/apps/frontend/src/app/expenses/page.tsx @@ -11,7 +11,7 @@ import { Button, } from '@chakra-ui/react'; import DropdownSelector from '../components/DropdownSelector'; -import { apiFetch } from '@/lib/api'; +import { useApi } from '@/hooks/useApi'; import { CiFilter } from 'react-icons/ci'; import { LuArrowDownUp } from 'react-icons/lu'; import { FaPlus } from 'react-icons/fa'; @@ -57,6 +57,8 @@ export default function ExpensePage() { } function ExpensePageContent() { + const api = useApi(); + // Data const [expenditures, setExpenditures] = useState([]); const [projects, setProjects] = useState([]); @@ -89,12 +91,11 @@ function ExpensePageContent() { // Modal const [showNewExpense, setShowNewExpense] = useState(false); - const token = typeof window !== 'undefined' ? localStorage.getItem('branch_access_token') ?? '' : ''; // Fetch expenditures async function fetchExpenditures() { try { - const json = await apiFetch<{ data: Expenditure[] }>('/expenditures', { token }); + const json = await api.get<{ data: Expenditure[] }>('/expenditures'); setExpenditures(json.data ?? []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load expenditures'); @@ -106,7 +107,7 @@ function ExpensePageContent() { // Fetch projects async function fetchProjects() { try { - const json = await apiFetch('/projects', { token }); + const json = await api.get('/projects'); setProjects(Array.isArray(json) ? json : []); } catch { // Projects fetch failure is non-critical @@ -173,7 +174,7 @@ function ExpensePageContent() { return (
- +
@@ -355,7 +356,6 @@ function ExpensePageContent() { open={showNewExpense} onClose={() => setShowNewExpense(false)} onSuccess={handleExpenseAdded} - token={token} categories={uniqueCategories} projects={projects} /> diff --git a/apps/frontend/src/app/login/page.tsx b/apps/frontend/src/app/login/page.tsx index 6a79314e..a996fa24 100644 --- a/apps/frontend/src/app/login/page.tsx +++ b/apps/frontend/src/app/login/page.tsx @@ -1,22 +1,40 @@ 'use client'; -import React, { useState } from 'react'; +import React, { Suspense, useState } from 'react'; import TextInputField from '../components/TextInputField'; +import SetPasswordForm from '../components/SetPasswordForm'; import Link from 'next/link'; import { Button } from '@chakra-ui/react'; -import { useAuth } from '@/context/AuthContext'; -import { useRouter } from 'next/navigation'; +import { useAuth, type LoginResult } from '@/context/AuthContext'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ApiError } from '@/lib/api'; +import { safeNextPath } from '@/lib/routes'; -export default function LoginPage() { - const { login } = useAuth(); +type Challenge = Extract; + +function LoginPageContent() { + const { login, respondToChallenge } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); + + // Where to land after signing in. AuthGate sets ?next= when it bounces an + // unauthenticated user off a protected page; safeNextPath rejects anything + // that isn't a same-origin path, so a crafted link can't redirect offsite. + const next = safeNextPath(searchParams.get('next')); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [emailError, setEmailError] = useState(''); const [passwordError, setPasswordError] = useState(''); + const [formError, setFormError] = useState(''); const [isLoading, setIsLoading] = useState(false); + // 'credentials' -> optionally 'newPassword'. Adding a TOTP step later is one + // more value here and one more case in handleResult — the context already + // returns the challenge and chains further ones. + const [step, setStep] = useState<'credentials' | 'newPassword'>('credentials'); + const [challenge, setChallenge] = useState(null); + function validate(): boolean { let valid = true; @@ -37,25 +55,93 @@ export default function LoginPage() { return valid; } + /** Report a failure honestly instead of blaming the user's credentials. */ + function reportError(err: unknown) { + if (err instanceof ApiError) { + if (err.status === 400 || err.status === 401) { + setPasswordError('Incorrect email or password. Please try again.'); + } else { + setFormError(err.message); + } + return; + } + // fetch rejects with a TypeError when the request never reached a server. + setFormError('Cannot reach the server. Check your connection and try again.'); + } + + function handleResult(result: LoginResult) { + if (result.status === 'authenticated') { + router.replace(next); + return; + } + + if (result.challengeName === 'NEW_PASSWORD_REQUIRED') { + setChallenge(result); + setStep('newPassword'); + return; + } + + setFormError( + `This account requires ${result.challengeName}, which isn't supported yet. Contact an administrator.`, + ); + } + async function handleLogin() { + setFormError(''); if (!validate()) return; setIsLoading(true); try { - await login(email, password); - router.push('/'); - } catch { - setPasswordError('Incorrect email or password. Please try again.'); + handleResult(await login(email, password)); + } catch (err) { + reportError(err); + } finally { + setIsLoading(false); + } + } + + async function handleNewPassword(newPassword: string) { + if (!challenge) return; + setFormError(''); + setIsLoading(true); + try { + handleResult(await respondToChallenge({ ...challenge, newPassword })); + } catch (err) { + reportError(err); } finally { setIsLoading(false); } } + if (step === 'newPassword') { + return ( +
+
+
+ Your account needs a new password before you can sign in. +
+ +
+
+ ); + } + return (

Login

BRANCH Accounting Platform
+ {formError && ( +

+ {formError} +

+ )}
); } + +// useSearchParams must be inside a Suspense boundary or `next build` fails +// under output: 'export'. +export default function LoginPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index 101bfb73..c5eb464e 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -1,13 +1,97 @@ -"use client"; -import ProjectCard from "./components/ProjectCard"; -import NavBar from "./components/Navbar"; +'use client'; -export default function Home() { +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; +import { LOGIN_PATH, POST_LOGIN_PATH, normalizePath } from '@/lib/routes'; +import FullPageSpinner from './components/FullPageSpinner'; + +/** + * Root route. + * + * This page used to render `` unconditionally, which is + * why opening the site looked like being signed in as an admin. It now renders + * nothing but a spinner and routes by session. + * + * It also absorbs the CloudFront SPA fallback: the distribution rewrites every + * 403/404 to `/index.html` (see infrastructure/aws/frontend_hosting.tf), so a + * deep link to a path with no exported document — `/projects/7/`, say — is served + * *this* document. We detect that and hand the URL back to the client router. + * + * Follow-up for whoever owns the infra: adding a CloudFront Function rule that + * maps `/projects/*` to the dynamic route's document would let those deep links + * hydrate directly instead of round-tripping through here. + */ + +const SPA_FALLBACK_KEY = 'branch_spa_fallback_path'; + +export default function RootPage() { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + + // usePathname() strips basePath automatically; window.location does not. + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''; + const rawPath = window.location.pathname.startsWith(basePath) + ? window.location.pathname.slice(basePath.length) + : window.location.pathname; + const actualPath = normalizePath(rawPath || '/'); + + if (actualPath !== '/') { + // We were served as the SPA fallback for some other URL. Retry it once — + // if the router cannot resolve it either, it hard-navigates, CloudFront + // serves this document again, and without the marker that loops forever. + if (window.sessionStorage.getItem(SPA_FALLBACK_KEY) === actualPath) { + window.sessionStorage.removeItem(SPA_FALLBACK_KEY); + setNotFound(true); + return; + } + window.sessionStorage.setItem(SPA_FALLBACK_KEY, actualPath); + router.replace(`${rawPath}${window.location.search}`); + return; + } + + window.sessionStorage.removeItem(SPA_FALLBACK_KEY); + + // A genuine visit to "/". Route by session, but only once it is known. + if (isLoading) return; + router.replace(isAuthenticated ? POST_LOGIN_PATH : LOGIN_PATH); + }, [isLoading, isAuthenticated, router]); + + if (notFound) return ; + return ; +} + +function NotFoundPanel() { return ( -
- -
-
+
+

+ Page not found +

+

+ That link doesn't point anywhere in BRANCH. +

+ + Back to dashboard +
); -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx index 6d37af05..cdb86b6d 100644 --- a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx +++ b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx @@ -7,7 +7,7 @@ import NavBar from '../../components/Navbar'; import ExpensesTable from '../../components/ExpensesTable'; import StaffCard from '../../components/StaffCard'; import type { Expenditure } from '../../components/ExpensesTable'; -import { apiFetch } from '@/lib/api'; +import { useApi } from '@/hooks/useApi'; type Project = { project_id: number; @@ -35,15 +35,12 @@ export default function ProjectPage() { const [project, setProject] = useState(null); const [expenditures, setExpenditures] = useState([]); + const api = useApi(); + const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const token = - typeof window !== 'undefined' - ? (localStorage.getItem('branch_access_token') ?? '') - : ''; - useEffect(() => { if (!id) return; @@ -52,12 +49,9 @@ export default function ProjectPage() { setError(null); try { const [projectData, expenditureData, memberData] = await Promise.all([ - apiFetch(`/projects/${id}`, { token }), - apiFetch(`/projects/${id}/expenditures`, { token }), - apiFetch<{ ok: boolean; body: { users: Member[] } }>( - `/projects/${id}/members`, - { token }, - ), + api.get(`/projects/${id}`), + api.get(`/projects/${id}/expenditures`), + api.get<{ ok: boolean; body: { users: Member[] } }>(`/projects/${id}/members`), ]); setProject(projectData); setExpenditures(Array.isArray(expenditureData) ? expenditureData : []); @@ -70,7 +64,8 @@ export default function ProjectPage() { } fetchAll(); - }, [id, token]); + // `api` has a stable identity (useMemo in useApi), so this does not loop. + }, [id, api]); // financial info const totalBudget = project?.total_budget ? parseFloat(project.total_budget) : 0; @@ -81,7 +76,7 @@ export default function ProjectPage() { if (loading) { return (
- +

Loading project...

@@ -93,7 +88,7 @@ export default function ProjectPage() { if (error || !project) { return (
- +

{error ?? 'Project not found.'} @@ -106,7 +101,7 @@ export default function ProjectPage() { // main page return (

- +
diff --git a/apps/frontend/src/app/projects/page.tsx b/apps/frontend/src/app/projects/page.tsx new file mode 100644 index 00000000..1034e880 --- /dev/null +++ b/apps/frontend/src/app/projects/page.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import NavBar from '../components/Navbar'; +import Header from '../components/Header'; +import ProjectCard from '../components/ProjectCard'; +import { useApi } from '@/hooks/useApi'; + +/** + * Projects index. The Navbar has always linked to /projects, but only the + * dynamic /projects/[id] route existed, so the link 404'd. + * + * A static page here coexists fine with the dynamic sibling under + * `output: 'export'`. + */ + +interface ProjectRow { + project_id: number; + name: string; + total_budget: number | string | null; +} + +export default function ProjectsListPage() { + const api = useApi(); + const [projects, setProjects] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const load = useCallback(async () => { + try { + setError(null); + setProjects(await api.get('/projects')); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not load projects'); + } finally { + setIsLoading(false); + } + }, [api]); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+ +
+
+
+

+ Projects +

+ + {isLoading &&

Loading projects…

} + {error &&

{error}

} + {!isLoading && !error && projects.length === 0 && ( +

No projects to show.

+ )} + +
+ {projects.map((project) => ( + + + + ))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/providers.tsx b/apps/frontend/src/app/providers.tsx index 7605b205..2542cbbd 100644 --- a/apps/frontend/src/app/providers.tsx +++ b/apps/frontend/src/app/providers.tsx @@ -2,11 +2,18 @@ import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; import { AuthProvider } from '@/context/AuthContext'; +import AuthGate from './components/AuthGate'; +// AuthGate lives here so every route is guarded by construction. Note that +// test/utils.tsx intentionally renders ChakraProvider + AuthProvider WITHOUT +// AuthGate, so page tests can exercise page content in isolation; the gate has +// its own test file. export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/apps/frontend/src/app/reports/page.tsx b/apps/frontend/src/app/reports/page.tsx index 5f0d7e77..35673be3 100644 --- a/apps/frontend/src/app/reports/page.tsx +++ b/apps/frontend/src/app/reports/page.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, Suspense } from 'react'; import { useQueryParams } from '@/hooks/useQueryParams'; import { useGenerateReport } from '@/hooks/useGenerateReport'; +import NavBar from '../components/Navbar'; import Header from '../components/Header'; import Pagination from '../components/Pagination'; import { @@ -14,7 +15,7 @@ import { Portal, VStack, } from '@chakra-ui/react'; -import { apiFetch } from '@/lib/api'; +import { useApi } from '@/hooks/useApi'; import { FaPlus } from 'react-icons/fa'; import { LuClipboardPenLine } from 'react-icons/lu'; import { RiDeleteBack2Line } from "react-icons/ri"; @@ -77,6 +78,8 @@ function ReportsPageContent() { // Data const [reports, setReports] = useState([]); const [projects, setProjects] = useState([]); + const api = useApi(); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -92,12 +95,11 @@ function ReportsPageContent() { }); const currentPage = parseInt(filters.page, 10) || 1; - const token = typeof window !== 'undefined' ? localStorage.getItem('branch_access_token') ?? '' : ''; // Fetch reports async function fetchReports() { try { - const json = await apiFetch<{ data: Report[] }>('/reports', { token }); + const json = await api.get<{ data: Report[] }>('/reports'); setReports(json.data ?? []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load reports'); @@ -109,7 +111,7 @@ function ReportsPageContent() { // Fetch projects (used to resolve project_id -> name if needed elsewhere) async function fetchProjects() { try { - const json = await apiFetch('/projects', { token }); + const json = await api.get('/projects'); const list = Array.isArray(json) ? json : []; setProjects(list); if (list.length > 0) { @@ -130,7 +132,7 @@ function ReportsPageContent() { generating, error: generateError, handleGenerate, - } = useGenerateReport({ token, onSuccess: fetchReports }); + } = useGenerateReport({ onSuccess: fetchReports }); useEffect(() => { @@ -189,6 +191,7 @@ function ReportsPageContent() { return (
+
diff --git a/apps/frontend/src/app/reset-password/page.tsx b/apps/frontend/src/app/reset-password/page.tsx index becf9c72..9e592f2a 100644 --- a/apps/frontend/src/app/reset-password/page.tsx +++ b/apps/frontend/src/app/reset-password/page.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useState, Suspense } from 'react'; -import TextInputField from '@/app/components/TextInputField'; +import Link from 'next/link'; +import SetPasswordForm from '@/app/components/SetPasswordForm'; import { Button } from '@chakra-ui/react'; import { useAuth } from '@/context/AuthContext'; import { useRouter, useSearchParams } from 'next/navigation'; @@ -14,47 +15,52 @@ function ResetPasswordContent() { const email = searchParams.get('email') ?? ''; const code = searchParams.get('code') ?? ''; - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [newPasswordError, setNewPasswordError] = useState(''); - const [confirmPasswordError, setConfirmPasswordError] = useState(''); + const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [submitted, setSubmitted] = useState(false); - function validate(): boolean { - let valid = true; - - const strongPassword = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/; - if (!newPassword || !strongPassword.test(newPassword)) { - setNewPasswordError('Password must be at least 8 characters with uppercase, lowercase, number, and symbol'); - valid = false; - } else { - setNewPasswordError(''); - } - - if (newPassword !== confirmPassword) { - setConfirmPasswordError('Password does not match'); - valid = false; - } else { - setConfirmPasswordError(''); - } - - return valid; - } - - async function handleResetPassword() { - if (!validate()) return; + async function handleResetPassword(newPassword: string) { setIsLoading(true); + setError(null); try { await resetPassword(email, code, newPassword); - } catch { - // expected without backend + // Only on success. This used to live in `finally`, so the + // "Password Changed" screen appeared even when the request failed + // and users believed a password that had not changed. + setSubmitted(true); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Could not reset your password. Please try again.', + ); } finally { setIsLoading(false); - setSubmitted(true); } } + // Without both query params there is nothing to submit; posting empty + // strings would just produce a confusing server-side error. + if (!email || !code) { + return ( +
+

+ Link expired +

+
+ This password reset link is invalid or has expired. Request a new one to + continue. +
+ + Request a new reset link + +
+ ); + } + if (submitted) { return (
@@ -75,34 +81,11 @@ function ResetPasswordContent() { } return ( -
-

Reset Password

-
- setNewPassword(value)} - /> - setConfirmPassword(value)} - /> -
- -
+ ); } diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index 166675f5..63ecddf5 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -1,186 +1,338 @@ 'use client'; -import { createContext, useContext, useEffect, useState } from 'react'; -import { apiFetch } from '@/lib/api'; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { ApiError, apiFetch } from '@/lib/api'; +import { + authedFetch, + endSession, + onSessionExpired, + refreshSession, +} from '@/lib/authClient'; +import { + clearTokens, + getAccessToken, + getRefreshToken, + getTokenExpMs, + saveTokens, +} from '@/lib/authTokens'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -interface User { - sub: string; +/** + * The signed-in user, as reported by GET /auth/me. + * + * IMPORTANT: `isAdmin` is NOT a JWT claim. It lives only in Postgres + * (`branch.users.is_admin`) and there is no pre-token-generation trigger, so + * decoding the ID token can never yield it. A Cognito *access* token does not + * carry `email` or `name` either. GET /auth/me is therefore the only source of + * truth for identity and role — do not "optimise" it away in favour of decoding + * a token locally. + */ +export interface AuthUser { + userId: number; + cognitoSub: string; email: string; - name?: string; + name: string; + isAdmin: boolean; + profileImage?: string | null; } -interface AuthTokens { - accessToken: string; - idToken: string; - refreshToken: string; +export type ChallengeName = + | 'NEW_PASSWORD_REQUIRED' + | 'SOFTWARE_TOKEN_MFA' + | 'SMS_MFA' + | 'EMAIL_OTP' + | 'SELECT_MFA_TYPE'; + +export type LoginResult = + | { status: 'authenticated' } + | { + status: 'challenge'; + challengeName: ChallengeName; + session: string; + email: string; + }; + +export interface ChallengeResponseInput { + challengeName: ChallengeName; + session: string; + email: string; + /** NEW_PASSWORD_REQUIRED */ + newPassword?: string; + /** SOFTWARE_TOKEN_MFA / SMS_MFA / EMAIL_OTP — no UI for these yet. */ + code?: string; + /** SELECT_MFA_TYPE */ + mfaType?: string; + /** Optional display name, when the pool requires the attribute. */ + name?: string; } interface AuthContextValue { - user: User | null; + user: AuthUser | null; isAuthenticated: boolean; + isAdmin: boolean; isLoading: boolean; - login: (email: string, password: string) => Promise; - register: (email: string, password: string, name: string) => Promise; - verifyEmail: (email: string, code: string) => Promise; - resendCode: (email: string) => Promise; + login: (email: string, password: string) => Promise; + respondToChallenge: (input: ChallengeResponseInput) => Promise; logout: () => Promise; - getAccessToken: () => string | null; + refresh: () => Promise; + reloadUser: () => Promise; forgotPassword: (email: string) => Promise; - resetPassword: (email: string, code: string, newPassword: string) => Promise; + resetPassword: ( + email: string, + code: string, + newPassword: string, + ) => Promise; } -// --------------------------------------------------------------------------- -// Backend response shapes -// --------------------------------------------------------------------------- +/* + * Deliberately absent: register / verifyEmail / resendCode. + * + * The backend still serves POST /auth/register, /auth/verify-email and + * /auth/resend-code, but BRANCH has no self-serve signup by design. It is an + * internal tool with an admin-managed roster, and `is_admin` lives in Postgres — + * a self-registered user would authenticate but have no meaningful authorization. + * Onboarding is admin-invite instead: an admin creates a `branch.users` row with + * a NULL `cognito_sub`, and the invitee's first registration claims it (see + * claim-on-register in lambdas/auth/handler.ts). AdminCreateUser with a + * temporary password works too — that path returns NEW_PASSWORD_REQUIRED, which + * the login page handles, and marks the email verified server-side so no + * verification-code screen is needed. + * + * Please don't re-add these to the context without a matching UI; they were + * previously exposed here and called from nowhere. + */ -interface LoginResponse { - AccessToken: string; - IdToken: string; - RefreshToken: string; +/** Raw shape of POST /auth/login and /auth/respond-challenge (PascalCase). */ +interface AuthRawResponse { + AccessToken?: string; + IdToken?: string; + RefreshToken?: string; + ChallengeName?: ChallengeName; + Session?: string; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const STORAGE_KEYS = { - ACCESS: 'branch_access_token', - ID: 'branch_id_token', - REFRESH: 'branch_refresh_token', -} as const; - -function decodeIdToken(token: string): User | null { - try { - const payload = token.split('.')[1]; - const padded = payload.replace(/-/g, '+').replace(/_/g, '/'); - const json = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '=')); - const claims = JSON.parse(json); - return { - sub: claims.sub, - email: claims.email, - name: claims.name ?? claims['cognito:username'], - }; - } catch { - return null; - } -} - -function saveTokens({ accessToken, idToken, refreshToken }: AuthTokens) { - localStorage.setItem(STORAGE_KEYS.ACCESS, accessToken); - localStorage.setItem(STORAGE_KEYS.ID, idToken); - localStorage.setItem(STORAGE_KEYS.REFRESH, refreshToken); -} - -function clearTokens() { - localStorage.removeItem(STORAGE_KEYS.ACCESS); - localStorage.removeItem(STORAGE_KEYS.ID); - localStorage.removeItem(STORAGE_KEYS.REFRESH); -} +/** Refresh this many ms before the access token actually expires. */ +const REFRESH_LEAD_MS = 120_000; +const MIN_REFRESH_DELAY_MS = 5_000; // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- +/** Guards against a malformed or empty /auth/me payload being treated as a session. */ +function isValidUser(candidate: unknown): candidate is AuthUser { + return ( + typeof candidate === 'object' && + candidate !== null && + typeof (candidate as AuthUser).cognitoSub === 'string' + ); +} + const AuthContext = createContext(null); export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); - // Restore session from localStorage on mount + const fetchMe = useCallback(() => authedFetch('/auth/me'), []); + + // Session bootstrap. Server-verified rather than "trust the local ID token", + // so a revoked or expired session no longer looks signed in. useEffect(() => { - const idToken = localStorage.getItem(STORAGE_KEYS.ID); - if (idToken) { - setUser(decodeIdToken(idToken)); - } - setIsLoading(false); - }, []); + let cancelled = false; - async function login(email: string, password: string) { - const data = await apiFetch('/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password }), - }); - const tokens: AuthTokens = { - accessToken: data.AccessToken, - idToken: data.IdToken, - refreshToken: data.RefreshToken, + (async () => { + try { + // Anonymous visitors make zero network calls, so isLoading settles on the + // first effect flush and no protected UI is ever painted. + if (!getAccessToken() && !getRefreshToken()) return; + const me = await fetchMe(); + if (!cancelled) setUser(isValidUser(me) ? me : null); + } catch { + clearTokens(); + if (!cancelled) setUser(null); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + + return () => { + cancelled = true; }; - saveTokens(tokens); - setUser(decodeIdToken(tokens.idToken)); - } + }, [fetchMe]); - async function register(email: string, password: string, name: string) { - await apiFetch('/auth/register', { - method: 'POST', - body: JSON.stringify({ email, password, name }), - }); - } + // Any endSession() anywhere — including from a background request — collapses + // into user === null, which AuthGate turns into a redirect. + useEffect(() => onSessionExpired(() => setUser(null)), []); - async function verifyEmail(email: string, code: string) { - await apiFetch('/auth/verify-email', { - method: 'POST', - body: JSON.stringify({ email, code }), - }); - } + // Proactive refresh, rescheduled from each new token's exp. Without this the + // session silently breaks after the access token's 1-hour lifetime. + useEffect(() => { + if (!user) return; - async function resendCode(email: string) { - await apiFetch('/auth/resend-code', { - method: 'POST', - body: JSON.stringify({ email }), - }); - } + let timer: ReturnType | undefined; + let cancelled = false; + + const schedule = () => { + if (cancelled) return; + const exp = getTokenExpMs(getAccessToken()); + if (exp === null) return; + const delay = Math.max( + exp - Date.now() - REFRESH_LEAD_MS, + MIN_REFRESH_DELAY_MS, + ); + timer = setTimeout(async () => { + if (cancelled) return; + if (await refreshSession()) schedule(); + else endSession(); + }, delay); + }; + + schedule(); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [user]); + + /** + * Turns a raw auth response into a LoginResult. + * + * Both login and respondToChallenge funnel through here, which is what makes a + * challenge chained *after* NEW_PASSWORD_REQUIRED (the normal TOTP-enrollment + * path) already work at this layer — only a UI step would be missing. It also + * throws when tokens are absent instead of persisting `undefined`, which used + * to write the literal string "undefined" into storage and report success. + */ + const consumeAuthResponse = useCallback( + async (data: AuthRawResponse, email: string): Promise => { + if (data.ChallengeName) { + if (!data.Session) { + throw new Error('Login challenge returned without a session'); + } + return { + status: 'challenge', + challengeName: data.ChallengeName, + session: data.Session, + email, + }; + } - async function logout() { - const accessToken = localStorage.getItem(STORAGE_KEYS.ACCESS); - if (accessToken) { - await apiFetch('/auth/logout', { + if (!data.AccessToken || !data.IdToken || !data.RefreshToken) { + throw new Error('Login response did not include tokens'); + } + + saveTokens({ + accessToken: data.AccessToken, + idToken: data.IdToken, + refreshToken: data.RefreshToken, + }); + + try { + const me = await fetchMe(); + if (!isValidUser(me)) throw new Error('Malformed /auth/me response'); + setUser(me); + } catch { + // Never leave a half-session behind: tokens present but no known user. + clearTokens(); + setUser(null); + throw new Error( + 'Signed in but could not load your profile. Please try again.', + ); + } + + return { status: 'authenticated' }; + }, + [fetchMe], + ); + + const login = useCallback( + async (email: string, password: string): Promise => { + const data = await apiFetch('/auth/login', { method: 'POST', - token: accessToken, - }).catch(() => { - // Best-effort — clear locally even if the server call fails + body: JSON.stringify({ email, password }), }); - } - clearTokens(); - setUser(null); - } + return consumeAuthResponse(data, email); + }, + [consumeAuthResponse], + ); - function getAccessToken() { - return localStorage.getItem(STORAGE_KEYS.ACCESS); - } + const respondToChallenge = useCallback( + async (input: ChallengeResponseInput): Promise => { + const data = await apiFetch('/auth/respond-challenge', { + method: 'POST', + body: JSON.stringify(input), + }); + return consumeAuthResponse(data, input.email); + }, + [consumeAuthResponse], + ); - - async function forgotPassword(email: string) { - await apiFetch('/auth/forgot-password', { + const logout = useCallback(async () => { + // retryOn401: false — refreshing a session we are discarding is pointless. + await authedFetch('/auth/logout', { method: 'POST', - body: JSON.stringify({ email }), + retryOn401: false, + }).catch(() => { + // Best effort: clear locally even if the server call fails. }); - } + endSession(); + setUser(null); + }, []); - async function resetPassword(email: string, code: string, newPassword: string) { - await apiFetch('/auth/reset-password', { + const reloadUser = useCallback(async () => { + try { + const me = await fetchMe(); + setUser(isValidUser(me) ? me : null); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + endSession(); + setUser(null); + return; + } + throw error; + } + }, [fetchMe]); + + const forgotPassword = useCallback(async (email: string) => { + await apiFetch('/auth/forgot-password', { method: 'POST', - body: JSON.stringify({ email, code, newPassword }), + body: JSON.stringify({ email }), }); - } + }, []); + const resetPassword = useCallback( + async (email: string, code: string, newPassword: string) => { + await apiFetch('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ email, code, newPassword }), + }); + }, + [], + ); return ( (path: string): Promise; + post(path: string, body?: unknown): Promise; + patch(path: string, body?: unknown): Promise; + put(path: string, body?: unknown): Promise; + del(path: string): Promise; + request(path: string, options?: AuthedRequestOptions): Promise; +} + +const api: Api = { + get: (path) => authedFetch(path, { method: 'GET' }), + post: (path, body) => + authedFetch(path, { + method: 'POST', + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + patch: (path, body) => + authedFetch(path, { + method: 'PATCH', + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + put: (path, body) => + authedFetch(path, { + method: 'PUT', + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + del: (path) => authedFetch(path, { method: 'DELETE' }), + request: (path, options) => authedFetch(path, options), +}; + +/** + * Returns a stable `Api`. The identity never changes, so it is safe to list in a + * `useEffect` dependency array — which matters because several pages already + * depend on their fetch helper there and a fresh object each render would loop. + */ +export function useApi(): Api { + return useMemo(() => api, []); +} diff --git a/apps/frontend/src/hooks/useGenerateReport.ts b/apps/frontend/src/hooks/useGenerateReport.ts index d08fb117..510c06c7 100644 --- a/apps/frontend/src/hooks/useGenerateReport.ts +++ b/apps/frontend/src/hooks/useGenerateReport.ts @@ -1,14 +1,14 @@ import { useState } from 'react'; -import { apiFetch } from '@/lib/api'; +import { useApi } from '@/hooks/useApi'; type FileType = 'pdf' | 'docx'; interface UseGenerateReportParams { - token: string; onSuccess: () => Promise | void; } -export function useGenerateReport({ token, onSuccess }: UseGenerateReportParams) { +export function useGenerateReport({ onSuccess }: UseGenerateReportParams) { + const api = useApi(); const [showGenerateModal, setShowGenerateModal] = useState(false); const [generateProjectId, setGenerateProjectId] = useState(''); const [generateFileType, setGenerateFileType] = useState('pdf'); @@ -23,18 +23,16 @@ export function useGenerateReport({ token, onSuccess }: UseGenerateReportParams) setGenerating(true); setError(null); try { - await apiFetch('/reports/generate', { - token, - method: 'POST', - body: JSON.stringify({ - project_id: parseInt(generateProjectId, 10), - file_type: generateFileType, - }), + await api.post('/reports/generate', { + project_id: parseInt(generateProjectId, 10), + file_type: generateFileType, }); await onSuccess(); setShowGenerateModal(false); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to generate report'); + setError( + err instanceof Error ? err.message : 'Failed to generate report', + ); } finally { setGenerating(false); } @@ -52,4 +50,4 @@ export function useGenerateReport({ token, onSuccess }: UseGenerateReportParams) setError, handleGenerate, }; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 6f7b4032..f68fec48 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -15,10 +15,45 @@ function resolveBaseUrl(path: string): string { return `http://localhost:${port}`; } +/** + * A failed HTTP response, carrying the status so callers can branch on it. + * + * This is what makes transparent token refresh possible: `authedFetch` needs to + * tell a 401 apart from a network failure or a 500, and a bare `Error` cannot + * express that. + */ +export class ApiError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(message: string, status: number, body?: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.body = body; + } +} + interface RequestOptions extends RequestInit { token?: string; } +/** Reads a JSON body, tolerating empty ones (e.g. a 204 from POST /auth/logout). */ +async function readBody(res: Response): Promise { + if (res.status === 204 || res.status === 205) return undefined; + try { + return await res.json(); + } catch { + return undefined; + } +} + +/** + * Low-level fetch wrapper. Deliberately dependency-free — it knows nothing about + * sessions, storage or refresh, which is what keeps the import graph acyclic: + * `authClient` imports this, not the other way round. Use `useApi()` / + * `authedFetch` for anything that needs the caller's access token. + */ export async function apiFetch( path: string, { token, headers, ...options }: RequestOptions = {}, @@ -32,10 +67,15 @@ export async function apiFetch( }, }); + const body = await readBody(res); + if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.message ?? res.statusText); + const message = + (body as { message?: string } | undefined)?.message ?? + res.statusText ?? + 'Request failed'; + throw new ApiError(message, res.status, body); } - return res.json() as Promise; + return body as T; } diff --git a/apps/frontend/src/lib/authClient.ts b/apps/frontend/src/lib/authClient.ts new file mode 100644 index 00000000..afe374b6 --- /dev/null +++ b/apps/frontend/src/lib/authClient.ts @@ -0,0 +1,147 @@ +import { ApiError, apiFetch } from './api'; +import { + clearTokens, + getAccessToken, + getRefreshToken, + isExpiredOrExpiring, + saveTokens, +} from './authTokens'; + +/** + * Session-aware fetch, deliberately outside React. + * + * It imports `api` but never React or `next/navigation`, and `AuthContext` + * imports it — so the graph stays one-way: + * + * api.ts <- authClient.ts <- AuthContext.tsx / hooks/useApi.ts + * + * Session death is announced upward through a listener registry rather than by + * navigating here, so there is exactly one redirect code path in the app + * (AuthGate reacting to `user === null`) regardless of what killed the session. + */ + +interface RefreshResponse { + AccessToken?: string; + IdToken?: string; +} + +type SessionExpiredListener = () => void; + +const listeners = new Set(); + +/** Subscribe to session death. Returns an unsubscribe function. */ +export function onSessionExpired(listener: SessionExpiredListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Clears stored tokens and notifies subscribers. Safe to call repeatedly. */ +export function endSession(): void { + clearTokens(); + for (const listener of listeners) { + try { + listener(); + } catch (error) { + console.error('onSessionExpired listener threw:', error); + } + } +} + +let refreshInFlight: Promise | null = null; + +/** + * Exchanges the stored refresh token for a new access/ID token pair. + * + * Single-flight: concurrent callers share one in-flight request, so a burst of + * simultaneous 401s produces exactly one POST /auth/refresh rather than one per + * request. Resolves false rather than throwing — callers treat that as "session + * is over". + */ +export function refreshSession(): Promise { + if (refreshInFlight) return refreshInFlight; + + const attempt = (async (): Promise => { + const refreshToken = getRefreshToken(); + if (!refreshToken) return false; + + try { + // apiFetch, not authedFetch: this call carries no bearer token and must + // never recurse back into the refresh path. + const data = await apiFetch('/auth/refresh', { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }); + + if (!data?.AccessToken || !data?.IdToken) return false; + + // No refreshToken here on purpose — Cognito does not return a new one, and + // saveTokens leaves the stored value alone when it is absent. + saveTokens({ accessToken: data.AccessToken, idToken: data.IdToken }); + return true; + } catch { + return false; + } + })(); + + refreshInFlight = attempt; + void attempt.finally(() => { + if (refreshInFlight === attempt) refreshInFlight = null; + }); + + return attempt; +} + +export interface AuthedRequestOptions extends RequestInit { + /** Set false to skip the refresh-and-retry dance (used when signing out). */ + retryOn401?: boolean; +} + +/** + * Performs a request with the caller's access token attached, refreshing it when + * needed. At most one refresh and one retry per call. + */ +export async function authedFetch( + path: string, + { retryOn401 = true, ...options }: AuthedRequestOptions = {}, +): Promise { + let token = getAccessToken(); + + if (!token) { + endSession(); + throw new ApiError('Not authenticated', 401); + } + + // Pre-emptive refresh: skips a round trip that would certainly 401. + if (isExpiredOrExpiring(token)) { + if (!(await refreshSession())) { + endSession(); + throw new ApiError('Session expired', 401); + } + token = getAccessToken(); + } + + try { + return await apiFetch(path, { ...options, token: token ?? undefined }); + } catch (error) { + const isUnauthorized = error instanceof ApiError && error.status === 401; + if (!isUnauthorized || !retryOn401) throw error; + + if (!(await refreshSession())) { + endSession(); + throw new ApiError('Session expired', 401); + } + + // Retry through apiFetch directly, so a second 401 propagates to the caller + // instead of looping. + return await apiFetch(path, { + ...options, + token: getAccessToken() ?? undefined, + }); + } +} + +/** Test-only: drops any in-flight refresh so state does not leak between cases. */ +export function __resetRefreshStateForTests(): void { + refreshInFlight = null; + listeners.clear(); +} diff --git a/apps/frontend/src/lib/authTokens.ts b/apps/frontend/src/lib/authTokens.ts new file mode 100644 index 00000000..40df2d8b --- /dev/null +++ b/apps/frontend/src/lib/authTokens.ts @@ -0,0 +1,128 @@ +/** + * The single owner of token storage. + * + * Nothing else in the app should touch `localStorage` for auth — a + * `grep -rn "branch_access_token" src/` should only ever match this file. + * Reading the key directly is how pages ended up sending unauthenticated + * requests: `localStorage.getItem(...) ?? ''` produces an empty string, and an + * empty token makes `apiFetch` silently omit the Authorization header. + */ + +export const STORAGE_KEYS = { + ACCESS: 'branch_access_token', + ID: 'branch_id_token', + REFRESH: 'branch_refresh_token', +} as const; + +/** + * `output: 'export'` prerenders client components at build time, so every + * accessor has to tolerate the absence of `window` or `next build` fails. + */ +function readKey(key: string): string | null { + if (typeof window === 'undefined') return null; + try { + return window.localStorage.getItem(key); + } catch { + // Private-browsing modes can throw on localStorage access. + return null; + } +} + +export function getAccessToken(): string | null { + return readKey(STORAGE_KEYS.ACCESS); +} + +export function getIdToken(): string | null { + return readKey(STORAGE_KEYS.ID); +} + +export function getRefreshToken(): string | null { + return readKey(STORAGE_KEYS.REFRESH); +} + +export interface StoredTokens { + accessToken: string; + idToken: string; + /** + * Optional on purpose. POST /auth/refresh returns only AccessToken and IdToken + * — Cognito does not re-issue a refresh token on REFRESH_TOKEN_AUTH — so + * refresh responses must not overwrite the stored one with `undefined`. + */ + refreshToken?: string; +} + +export function saveTokens({ + accessToken, + idToken, + refreshToken, +}: StoredTokens): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(STORAGE_KEYS.ACCESS, accessToken); + window.localStorage.setItem(STORAGE_KEYS.ID, idToken); + if (refreshToken) { + window.localStorage.setItem(STORAGE_KEYS.REFRESH, refreshToken); + } + } catch { + // Storage unavailable — the session simply won't survive a reload. + } +} + +export function clearTokens(): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.removeItem(STORAGE_KEYS.ACCESS); + window.localStorage.removeItem(STORAGE_KEYS.ID); + window.localStorage.removeItem(STORAGE_KEYS.REFRESH); + } catch { + // Nothing useful to do. + } +} + +/** + * Decodes a JWT payload without verifying it. + * + * Only used to read `exp` for refresh scheduling. Identity and `isAdmin` come + * from GET /auth/me — `is_admin` lives solely in Postgres and is not a token + * claim, so it can never be recovered from here. + */ +export function decodeJwtPayload>( + token: string, +): T | null { + try { + const segment = token.split('.')[1]; + if (!segment) return null; + const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + '=', + ); + return JSON.parse(atob(padded)) as T; + } catch { + return null; + } +} + +/** Token expiry as epoch milliseconds, or null if it cannot be determined. */ +export function getTokenExpMs(token: string | null): number | null { + if (!token) return null; + const payload = decodeJwtPayload<{ exp?: number }>(token); + if (!payload || typeof payload.exp !== 'number') return null; + return payload.exp * 1000; +} + +/** + * True when the token is already expired or will be within `skewMs`. + * Lets callers refresh pre-emptively instead of spending a round trip on a + * request that is guaranteed to 401. + */ +export function isExpiredOrExpiring( + token: string | null, + skewMs = 30_000, +): boolean { + const exp = getTokenExpMs(token); + // An undecodable token is treated as usable: let the server be the judge + // rather than locking the user out over a parsing quirk. + if (exp === null) return false; + return exp - skewMs <= Date.now(); +} diff --git a/apps/frontend/src/lib/routes.ts b/apps/frontend/src/lib/routes.ts new file mode 100644 index 00000000..610a3003 --- /dev/null +++ b/apps/frontend/src/lib/routes.ts @@ -0,0 +1,75 @@ +/** + * Route access policy. + * + * Deliberately protected-by-default: any new page under `src/app/` is gated + * without anyone remembering to opt in. That is the property that keeps the + * "anonymous visitor sees the admin shell" bug from recurring — the previous + * design had no guard at all, so every page was implicitly public. + */ + +export type RouteAccess = 'public' | 'protected' | 'bootstrap'; + +export const LOGIN_PATH = '/login'; +export const POST_LOGIN_PATH = '/dashboard'; + +/** Reachable without a session. Authenticated users get bounced off these. */ +const PUBLIC_PREFIXES = [ + '/login', + '/forgot-password', + '/reset-password', +] as const; + +/** Require `isAdmin` on top of authentication. */ +const ADMIN_PREFIXES = ['/expenses', '/reports', '/accounts'] as const; + +/** + * Strips the trailing slash and lowercases. + * + * Required, not cosmetic: `next.config.ts` sets `trailingSlash: true`, so + * `usePathname()` yields `/login/` in production but `/login` in dev and tests. + * Comparing raw pathnames passes every test and then misroutes in production. + */ +export function normalizePath(pathname: string): string { + if (!pathname) return '/'; + const lower = pathname.toLowerCase(); + const trimmed = lower.length > 1 ? lower.replace(/\/+$/, '') : lower; + return trimmed === '' ? '/' : trimmed; +} + +/** True when `path` equals `prefix` or is a descendant segment of it. */ +function matchesPrefix(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}/`); +} + +export function classifyRoute(pathname: string): RouteAccess { + const path = normalizePath(pathname); + if (path === '/') return 'bootstrap'; + if (PUBLIC_PREFIXES.some((prefix) => matchesPrefix(path, prefix))) + return 'public'; + return 'protected'; +} + +export function requiresAdmin(pathname: string): boolean { + const path = normalizePath(pathname); + return ADMIN_PREFIXES.some((prefix) => matchesPrefix(path, prefix)); +} + +/** + * Validates a `?next=` redirect target. + * + * Only same-origin absolute paths are accepted, so a crafted login link cannot + * bounce a freshly-authenticated user to an attacker's site. Rejects + * protocol-relative (`//evil.com`), absolute URLs, and backslash tricks that + * some browsers normalise to slashes. + */ +export function safeNextPath( + raw: string | null | undefined, + fallback = POST_LOGIN_PATH, +): string { + if (!raw) return fallback; + if (!raw.startsWith('/')) return fallback; + if (raw.startsWith('//')) return fallback; + if (raw.includes('\\')) return fallback; + if (raw.includes('://')) return fallback; + return raw; +} diff --git a/apps/frontend/test/app/RootPage.test.tsx b/apps/frontend/test/app/RootPage.test.tsx new file mode 100644 index 00000000..ac2ac9e4 --- /dev/null +++ b/apps/frontend/test/app/RootPage.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import RootPage from '@/app/page'; + +const mockReplace = jest.fn(); + +// Stable object: the real useRouter returns a stable reference, and returning a +// fresh one here would re-run effects that list `router` as a dependency. +const mockRouter = { replace: mockReplace, push: jest.fn() }; + +jest.mock('next/navigation', () => ({ + useRouter: () => mockRouter, + usePathname: () => '/', + useSearchParams: () => new URLSearchParams(), +})); + +let authState = { isAuthenticated: false, isAdmin: false, isLoading: false }; + +jest.mock('../../src/context/AuthContext', () => ({ + ...jest.requireActual('../../src/context/AuthContext'), + useAuth: () => authState, +})); + +/** jsdom's window.location is non-configurable; drive it through history. */ +function setLocation(pathname: string, search = '') { + window.history.replaceState({}, '', `${pathname}${search}`); +} + +beforeEach(() => { + jest.clearAllMocks(); + sessionStorage.clear(); + setLocation('/'); + authState = { isAuthenticated: false, isAdmin: false, isLoading: false }; +}); + +describe('RootPage', () => { + it('never renders the app shell', () => { + // The reported bug: this page used to render + // unconditionally, so opening the site looked like being signed in. + authState = { isAuthenticated: false, isAdmin: false, isLoading: true }; + const { container } = render(); + + expect(container.querySelector('nav')).toBeNull(); + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument(); + }); + + it('sends an anonymous visitor to /login', async () => { + render(); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/login')); + }); + + it('sends an authenticated user to /dashboard', async () => { + authState = { isAuthenticated: true, isAdmin: false, isLoading: false }; + render(); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/dashboard')); + }); + + it('waits for the session before routing', () => { + authState = { isAuthenticated: false, isAdmin: false, isLoading: true }; + render(); + + expect(mockReplace).not.toHaveBeenCalled(); + }); + + describe('CloudFront SPA fallback', () => { + it('re-routes to the deep link it was served in place of', async () => { + // CloudFront rewrites 403/404 to /index.html, so this document can be + // served for a URL that has no exported page of its own. + setLocation('/projects/7/', '?tab=expenses'); + render(); + + await waitFor(() => + expect(mockReplace).toHaveBeenCalledWith('/projects/7/?tab=expenses'), + ); + }); + + it('shows a not-found panel instead of looping when the deep link cannot resolve', async () => { + setLocation('/projects/7/'); + const { unmount } = render(); + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); + unmount(); + + // Second pass: the router hard-navigated and CloudFront served us again. + mockReplace.mockClear(); + render(); + + await waitFor(() => expect(screen.getByText('Page not found')).toBeInTheDocument()); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('clears the fallback marker on a genuine visit to the root', async () => { + setLocation('/projects/7/'); + const first = render(); + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); + first.unmount(); + + setLocation('/'); + mockReplace.mockClear(); + render(); + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/login')); + // A later deep link must get a fresh retry rather than an instant 404. + expect(sessionStorage.getItem('branch_spa_fallback_path')).toBeNull(); + }); + }); +}); diff --git a/apps/frontend/test/components/AddExpenseModal.test.tsx b/apps/frontend/test/components/AddExpenseModal.test.tsx index 027620af..bfb55d8c 100644 --- a/apps/frontend/test/components/AddExpenseModal.test.tsx +++ b/apps/frontend/test/components/AddExpenseModal.test.tsx @@ -1,9 +1,10 @@ import { render, screen, fireEvent, waitFor } from '../utils'; import AddExpenseModal from '@/app/components/AddExpenseModal'; -import { apiFetch } from '@/lib/api'; +import { authedFetch as apiFetch } from '@/lib/authClient'; -jest.mock('../../src/lib/api', () => ({ - apiFetch: jest.fn(), +jest.mock('../../src/lib/authClient', () => ({ + ...jest.requireActual('../../src/lib/authClient'), + authedFetch: jest.fn(), })); // Mock DropdownSelector — render a simple native select so we can drive it @@ -60,7 +61,6 @@ const baseProps = { open: true, onClose: jest.fn(), onSuccess: jest.fn(), - token: 'test-token', categories: ['Travel Foreign', 'Supplies'], projects: [ { project_id: 1, name: 'Project Name 1' }, @@ -149,7 +149,6 @@ describe('AddExpenseModal Component', () => { '/expenditures', expect.objectContaining({ method: 'POST', - token: 'test-token', body: JSON.stringify({ projectID: 2, amount: 12000, diff --git a/apps/frontend/test/components/AuthGate.test.tsx b/apps/frontend/test/components/AuthGate.test.tsx new file mode 100644 index 00000000..9dd4cbbf --- /dev/null +++ b/apps/frontend/test/components/AuthGate.test.tsx @@ -0,0 +1,143 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AuthGate from '@/app/components/AuthGate'; + +// jest.setup.ts returns a fresh router spy on every call, so redirects need a +// local mock with stable spies and a mutable pathname. +const mockReplace = jest.fn(); +let currentPath = '/dashboard'; + +// Stable object: the real useRouter returns a stable reference, and returning a +// fresh one here would re-run effects that list `router` as a dependency. +const mockRouter = { replace: mockReplace, push: jest.fn() }; + +jest.mock('next/navigation', () => ({ + useRouter: () => mockRouter, + usePathname: () => currentPath, + useSearchParams: () => new URLSearchParams(), +})); + +let authState = { isAuthenticated: false, isAdmin: false, isLoading: false }; + +jest.mock('../../src/context/AuthContext', () => ({ + ...jest.requireActual('../../src/context/AuthContext'), + useAuth: () => authState, +})); + +function renderGate() { + return render( + +
secret
+
, + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + currentPath = '/dashboard'; + authState = { isAuthenticated: false, isAdmin: false, isLoading: false }; +}); + +describe('AuthGate', () => { + describe('while the session is still resolving', () => { + it('does not redirect', () => { + // Regression guard: redirecting before rehydration would bounce a + // returning user to /login during their own bootstrap. + authState = { isAuthenticated: false, isAdmin: false, isLoading: true }; + renderGate(); + + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + }); + }); + + describe('protected routes', () => { + it('redirects an anonymous visitor to /login with a next param', () => { + currentPath = '/expenses'; + renderGate(); + + expect(mockReplace).toHaveBeenCalledWith('/login?next=%2Fexpenses'); + }); + + it('does not render protected children to an anonymous visitor', () => { + // The redirect is asynchronous; without render suppression the page would + // mount and start fetching in the meantime. + renderGate(); + + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + }); + + it('renders children for an authenticated user', () => { + authState = { isAuthenticated: true, isAdmin: false, isLoading: false }; + renderGate(); + + expect(screen.getByTestId('protected-content')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + }); + + describe('public routes', () => { + it('bounces an authenticated user off /login', () => { + currentPath = '/login'; + authState = { isAuthenticated: true, isAdmin: false, isLoading: false }; + renderGate(); + + expect(mockReplace).toHaveBeenCalledWith('/dashboard'); + }); + + it('lets an anonymous visitor see /login', () => { + currentPath = '/login'; + renderGate(); + + expect(screen.getByTestId('protected-content')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('lets an anonymous visitor see /forgot-password', () => { + currentPath = '/forgot-password'; + renderGate(); + + expect(screen.getByTestId('protected-content')).toBeInTheDocument(); + }); + }); + + describe('admin routes', () => { + it('shows a no-access panel to a non-admin instead of redirecting', () => { + currentPath = '/reports'; + authState = { isAuthenticated: true, isAdmin: false, isLoading: false }; + renderGate(); + + expect(screen.getByText(/don't have access to this page/i)).toBeInTheDocument(); + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + // A redirect could loop if the admin flag changes mid-session. + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('renders children for an admin', () => { + currentPath = '/reports'; + authState = { isAuthenticated: true, isAdmin: true, isLoading: false }; + renderGate(); + + expect(screen.getByTestId('protected-content')).toBeInTheDocument(); + }); + + it('sends an anonymous visitor to login rather than the no-access panel', () => { + currentPath = '/accounts'; + renderGate(); + + expect(mockReplace).toHaveBeenCalledWith('/login?next=%2Faccounts'); + expect(screen.queryByText(/don't have access/i)).not.toBeInTheDocument(); + }); + }); + + describe('trailing slashes', () => { + it('classifies production-style paths the same as dev ones', () => { + // next.config.ts sets trailingSlash: true, so production emits "/login/". + currentPath = '/login/'; + authState = { isAuthenticated: true, isAdmin: false, isLoading: false }; + renderGate(); + + expect(mockReplace).toHaveBeenCalledWith('/dashboard'); + }); + }); +}); diff --git a/apps/frontend/test/components/Header.test.tsx b/apps/frontend/test/components/Header.test.tsx index 539a3e9d..2d9d9ab3 100644 --- a/apps/frontend/test/components/Header.test.tsx +++ b/apps/frontend/test/components/Header.test.tsx @@ -1,15 +1,56 @@ -import { render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; // Add this line to fix the error +// Uses the shared render from ../utils, which wraps in AuthProvider — Header +// now reads the session so it can show who is signed in. +import { render, screen, waitFor } from '../utils'; +import '@testing-library/jest-dom'; import Header from '../../src/app/components/Header'; +import { STORAGE_KEYS } from '@/lib/authTokens'; +import { __resetRefreshStateForTests } from '@/lib/authClient'; + +const ME = { + userId: 7, + cognitoSub: 'sub-123', + email: 'jane@example.com', + name: 'Jane Doe', + isAdmin: false, +}; + +function makeToken(claims: Record) { + const payload = btoa(JSON.stringify(claims)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`; +} + +/** Seeds tokens and stubs GET /auth/me so AuthProvider resolves a session. */ +function signIn(me: Record = ME) { + localStorage.setItem( + STORAGE_KEYS.ACCESS, + makeToken({ sub: 'sub-123', exp: Math.floor(Date.now() / 1000) + 3600 }), + ); + localStorage.setItem(STORAGE_KEYS.ID, makeToken({ sub: 'sub-123' })); + localStorage.setItem(STORAGE_KEYS.REFRESH, 'refresh-token'); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => me, + } as unknown as Response); +} + +beforeEach(() => { + localStorage.clear(); + __resetRefreshStateForTests(); +}); + +afterEach(() => jest.restoreAllMocks()); describe('Header Component', () => { it('renders the default title when no props are provided', () => { render(
); expect(screen.getByText(/BRANCH Accounting Platform/i)).toBeInTheDocument(); }); -}); -it('renders a custom title when the text prop is provided', () => { + it('renders a custom title when the text prop is provided', () => { render(
); expect(screen.getByText(/Custom Title/i)).toBeInTheDocument(); }); @@ -18,3 +59,35 @@ it('renders a custom title when the text prop is provided', () => { render(
★} />); expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); }); + + it('falls back to the placeholder avatar when signed out', () => { + render(
); + expect(screen.getByAltText('Profile Icon')).toBeInTheDocument(); + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument(); + }); + + it('shows the signed-in user name and email', async () => { + signIn(); + render(
); + + await waitFor(() => expect(screen.getByText('Jane Doe')).toBeInTheDocument()); + expect(screen.getByText('jane@example.com')).toBeInTheDocument(); + // The placeholder is replaced by real identity. + expect(screen.queryByAltText('Profile Icon')).not.toBeInTheDocument(); + }); + + it('shows an Admin badge only for admins', async () => { + signIn({ ...ME, isAdmin: true }); + render(
); + + await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument()); + }); + + it('does not show an Admin badge for a non-admin', async () => { + signIn({ ...ME, isAdmin: false }); + render(
); + + await waitFor(() => expect(screen.getByText('Jane Doe')).toBeInTheDocument()); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/test/components/LoginPage.test.tsx b/apps/frontend/test/components/LoginPage.test.tsx index ec3bac08..f214e5df 100644 --- a/apps/frontend/test/components/LoginPage.test.tsx +++ b/apps/frontend/test/components/LoginPage.test.tsx @@ -1,41 +1,238 @@ -import { render, screen } from '../utils'; +import { render, screen, waitFor } from '../utils'; +import userEvent from '@testing-library/user-event'; import LoginPage from '@/app/login/page'; +import { ApiError } from '@/lib/api'; +// The global next/navigation mock in jest.setup.ts hands back a fresh spy on +// every call, so redirects cannot be asserted against it. A local mock with +// stable spies is required — and it must provide useSearchParams, which the page +// now uses to read ?next=. const mockPush = jest.fn(); +const mockReplace = jest.fn(); +let searchParams = new URLSearchParams(); jest.mock('next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, + useRouter: () => ({ push: mockPush, replace: mockReplace }), + usePathname: () => '/login', + useSearchParams: () => searchParams, +})); + +const mockLogin = jest.fn(); +const mockRespondToChallenge = jest.fn(); + +jest.mock('../../src/context/AuthContext', () => ({ + ...jest.requireActual('../../src/context/AuthContext'), + useAuth: () => ({ + login: mockLogin, + respondToChallenge: mockRespondToChallenge, }), })); +async function fillCredentials(email = 'jane@example.com', password = 'Password123!') { + await userEvent.type(screen.getByPlaceholderText('Enter email address'), email); + await userEvent.type(screen.getByPlaceholderText('Enter password'), password); +} + +function submit() { + return userEvent.click(screen.getByRole('button', { name: 'Login' })); +} + +beforeEach(() => { + jest.clearAllMocks(); + searchParams = new URLSearchParams(); +}); + describe('Login Page Component', () => { - it('renders the login heading', () => { - render(); - expect(screen.getByText('Login', { selector: 'h1' })).toBeInTheDocument(); - }); + describe('rendering', () => { + it('renders the login heading', () => { + render(); + expect(screen.getByText('Login', { selector: 'h1' })).toBeInTheDocument(); + }); + + it('renders the branch subheading', () => { + render(); + expect( + screen.getByText('BRANCH Accounting Platform', { selector: 'h5' }), + ).toBeInTheDocument(); + }); + + it('renders the email and password input fields', () => { + render(); + expect(screen.getByPlaceholderText('Enter email address')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter password')).toBeInTheDocument(); + }); - it('renders the branch subheading', () => { - render(); - expect(screen.getByText('BRANCH Accounting Platform', { selector: 'h5' })).toBeInTheDocument(); + it('renders the login button', () => { + render(); + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument(); + }); + + it('renders the forgot password link pointing to /forgot-password', () => { + render(); + const link = screen.getByRole('link', { name: 'Forgot password?' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/forgot-password'); + }); }); - it('renders the email and password input fields', () => { - render(); - expect(screen.getByPlaceholderText('Enter email address')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Enter password')).toBeInTheDocument(); + describe('submitting', () => { + it('signs in and redirects to the dashboard', async () => { + mockLogin.mockResolvedValue({ status: 'authenticated' }); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => + expect(mockLogin).toHaveBeenCalledWith('jane@example.com', 'Password123!'), + ); + expect(mockReplace).toHaveBeenCalledWith('/dashboard'); + }); + + it('honours a ?next= target', async () => { + searchParams = new URLSearchParams('next=/expenses'); + mockLogin.mockResolvedValue({ status: 'authenticated' }); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/expenses')); + }); + + it('ignores an off-site ?next= target', async () => { + searchParams = new URLSearchParams('next=//evil.example.com'); + mockLogin.mockResolvedValue({ status: 'authenticated' }); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/dashboard')); + }); + + it('does not call login when validation fails', async () => { + render(); + + await userEvent.type( + screen.getByPlaceholderText('Enter email address'), + 'not-an-email', + ); + await submit(); + + expect(mockLogin).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); + }); }); - it('renders the login button', () => { - render(); - const button = screen.getByRole('button', { name: 'Login' }); - expect(button).toBeInTheDocument(); + describe('error handling', () => { + it('reports bad credentials on a 401 and does not navigate', async () => { + mockLogin.mockRejectedValue(new ApiError('Invalid email or password', 401)); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => + expect(screen.getByText(/Incorrect email or password/i)).toBeInTheDocument(), + ); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('distinguishes a network failure from bad credentials', async () => { + // fetch rejects with a TypeError when the request never reached a server. + mockLogin.mockRejectedValue(new TypeError('Failed to fetch')); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => + expect(screen.getByText(/Cannot reach the server/i)).toBeInTheDocument(), + ); + expect(screen.queryByText(/Incorrect email or password/i)).not.toBeInTheDocument(); + }); + + it('surfaces the real message on a server error', async () => { + mockLogin.mockRejectedValue(new ApiError('Database unavailable', 500)); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => + expect(screen.getByText('Database unavailable')).toBeInTheDocument(), + ); + }); }); - it('renders the forgot password link pointing to /forgot-password', () => { - render(); - const link = screen.getByRole('link', { name: 'Forgot password?' }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', '/forgot-password'); + describe('challenges', () => { + const npr = { + status: 'challenge', + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'sess-1', + email: 'jane@example.com', + }; + + it('shows the set-password step for NEW_PASSWORD_REQUIRED without navigating', async () => { + mockLogin.mockResolvedValue(npr); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => + expect(screen.getByText('Set a new password')).toBeInTheDocument(), + ); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('completes the challenge and then redirects', async () => { + mockLogin.mockResolvedValue(npr); + mockRespondToChallenge.mockResolvedValue({ status: 'authenticated' }); + render(); + + await fillCredentials(); + await submit(); + await waitFor(() => + expect(screen.getByText('Set a new password')).toBeInTheDocument(), + ); + + await userEvent.type( + screen.getByPlaceholderText('Enter new password'), + 'NewPassword1!', + ); + await userEvent.type(screen.getByPlaceholderText('Retype password'), 'NewPassword1!'); + await userEvent.click( + screen.getByRole('button', { name: /Set password and sign in/i }), + ); + + await waitFor(() => + expect(mockRespondToChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'sess-1', + newPassword: 'NewPassword1!', + }), + ), + ); + expect(mockReplace).toHaveBeenCalledWith('/dashboard'); + }); + + it('explains that an MFA challenge is not supported yet', async () => { + mockLogin.mockResolvedValue({ + status: 'challenge', + challengeName: 'SOFTWARE_TOKEN_MFA', + session: 'sess-1', + email: 'jane@example.com', + }); + render(); + + await fillCredentials(); + await submit(); + + await waitFor(() => expect(screen.getByText(/SOFTWARE_TOKEN_MFA/)).toBeInTheDocument()); + expect(mockReplace).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/frontend/test/components/Navbar.test.tsx b/apps/frontend/test/components/Navbar.test.tsx index c3f633fc..26a54275 100644 --- a/apps/frontend/test/components/Navbar.test.tsx +++ b/apps/frontend/test/components/Navbar.test.tsx @@ -24,8 +24,16 @@ describe("NavBar", () => { expect(screen.getAllByAltText("Branch").length).toBeGreaterThan(0); }); + it("does not link to routes that do not exist", () => { + // Regression guard: the Navbar used to link to /profile and /logout, and + // defaulted /dashboard and /projects to pages that had never been built. + render(); + expect(screen.queryByText("Profile")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Log Out" })).not.toBeInTheDocument(); + }); + it("renders all admin nav items by default", () => { - render(); + render(); const labels = [ "Dashboard", "Projects", @@ -34,7 +42,6 @@ describe("NavBar", () => { "Expenses", "Reports", "Accounts", - "Profile", "Log Out", ]; labels.forEach((label) => { @@ -45,25 +52,25 @@ describe("NavBar", () => { // ── Role-based visibility ───────────────────────────────────────────────── it("hides admin-only items for standard role", () => { - render(); + render(); expect(screen.queryByText("Expenses")).not.toBeInTheDocument(); expect(screen.queryByText("Reports")).not.toBeInTheDocument(); expect(screen.queryByText("Accounts")).not.toBeInTheDocument(); }); it("hides admin-only items for limited role", () => { - render(); + render(); expect(screen.queryByText("Expenses")).not.toBeInTheDocument(); expect(screen.queryByText("Reports")).not.toBeInTheDocument(); expect(screen.queryByText("Accounts")).not.toBeInTheDocument(); }); it("shows shared items for all roles", () => { - const sharedItems = ["Dashboard", "Projects", "Donors", "Donations", "Profile", "Log Out"]; + const sharedItems = ["Dashboard", "Projects", "Donors", "Donations", "Log Out"]; const roles: UserRole[] = ["admin", "standard", "limited"]; roles.forEach((role) => { - const { unmount } = render(); + const { unmount } = render(); sharedItems.forEach((label) => { expect(screen.getAllByText(label).length).toBeGreaterThan(0); }); @@ -88,8 +95,8 @@ describe("NavBar", () => { it("does not mark non-active links with aria-current", () => { render(); - const profileLinks = screen.getAllByRole("link", { name: "Profile" }); - profileLinks.forEach((link) => { + const donorLinks = screen.getAllByRole("link", { name: "Donors" }); + donorLinks.forEach((link) => { expect(link.getAttribute("aria-current")).toBeNull(); }); }); @@ -128,7 +135,7 @@ describe("NavBar", () => { // ── Nav links ───────────────────────────────────────────────────────────── it("nav links point to correct hrefs", () => { - render(); + render(); const expectedHrefs: Record = { Dashboard: "/dashboard", Projects: "/projects", @@ -137,7 +144,6 @@ describe("NavBar", () => { Expenses: "/expenses", Reports: "/reports", Accounts: "/accounts", - Profile: "/profile", }; Object.entries(expectedHrefs).forEach(([label, href]) => { const links = screen.getAllByRole("link", { name: label }); diff --git a/apps/frontend/test/components/NewPasswordForm.test.tsx b/apps/frontend/test/components/NewPasswordForm.test.tsx deleted file mode 100644 index 031971a4..00000000 --- a/apps/frontend/test/components/NewPasswordForm.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { render, screen } from '../utils'; -import NewPasswordForm from '@/app/components/NewPasswordForm'; - - -describe('New Password Form Component', () => { - it('renders the form heading', () => { - render(); - expect(screen.getByText('Reset Password', { selector: 'h1' })).toBeInTheDocument(); - }); - - it('renders the 2 password input fields', () => { - render(); - expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Retype password')).toBeInTheDocument(); - }); - - it('renders the reset password button', () => { - render(); - const button = screen.getByRole('button', { name: 'Reset Password' }); - expect(button).toBeInTheDocument(); - }) -}); \ No newline at end of file diff --git a/apps/frontend/test/components/ProjectPage.test.tsx b/apps/frontend/test/components/ProjectPage.test.tsx index 5150d357..e033802f 100644 --- a/apps/frontend/test/components/ProjectPage.test.tsx +++ b/apps/frontend/test/components/ProjectPage.test.tsx @@ -3,8 +3,9 @@ import { render, screen, waitFor } from '../utils'; import ProjectPage from '@/app/projects/[id]/page'; const mockApiFetch = jest.fn(); -jest.mock('../../src/lib/api', () => ({ - apiFetch: (...args: Parameters) => mockApiFetch(...args), +jest.mock('../../src/lib/authClient', () => ({ + ...jest.requireActual('../../src/lib/authClient'), + authedFetch: (...args: Parameters) => mockApiFetch(...args), })); jest.mock('next/navigation', () => ({ diff --git a/apps/frontend/test/components/ReportsPage.test.tsx b/apps/frontend/test/components/ReportsPage.test.tsx index 1d07e83d..df5bec34 100644 --- a/apps/frontend/test/components/ReportsPage.test.tsx +++ b/apps/frontend/test/components/ReportsPage.test.tsx @@ -1,10 +1,11 @@ import { render, screen, waitFor, within } from '../utils'; import userEvent from '@testing-library/user-event'; import ReportsPage from '@/app/reports/page'; -import { apiFetch } from '@/lib/api'; +import { authedFetch as apiFetch } from '@/lib/authClient'; -jest.mock('../../src/lib/api', () => ({ - apiFetch: jest.fn(), +jest.mock('../../src/lib/authClient', () => ({ + ...jest.requireActual('../../src/lib/authClient'), + authedFetch: jest.fn(), })); jest.mock('../../src/hooks/useQueryParams', () => ({ diff --git a/apps/frontend/test/components/ResetLinkSet.test.tsx b/apps/frontend/test/components/ResetLinkSet.test.tsx deleted file mode 100644 index 1781083c..00000000 --- a/apps/frontend/test/components/ResetLinkSet.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { render, screen } from '../utils'; -import ResetLinkSet from '@/app/components/ResetLinkSet'; - -describe('ResetLinkSet Component', () => { - it('renders the heading', () => { - render(); - expect(screen.getByText('Reset Link Sent!', { selector: 'h1' })).toBeInTheDocument(); - }); - - it('renders the subheading', () => { - render(); - expect(screen.getByText(/we sent a reset link/i, { selector: 'h5' })).toBeInTheDocument(); - }); - - it('renders the request reset link again button', () => { - render(); - expect(screen.getByRole('button', { name: 'Request reset link again' })).toBeInTheDocument(); - }); - - it('renders the back to login link', () => { - render(); - const link = screen.getByRole('link', { name: 'Back to login' }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', '#'); - }); -}); diff --git a/apps/frontend/test/components/ResetPasswordConfirmation.test.tsx b/apps/frontend/test/components/ResetPasswordConfirmation.test.tsx deleted file mode 100644 index c2911051..00000000 --- a/apps/frontend/test/components/ResetPasswordConfirmation.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { render, screen } from '../utils'; -import ResetPasswordConfirmation from '@/app/components/ResetPasswordConfirmation'; - -const mockPush = jest.fn(); - -jest.mock('next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - }), -})); - - -describe('Reset Password Confirmation Page Component', () => { - - it('renders the page heading', () => { - render(); - expect(screen.getByText('Password Changed', { selector: 'h1' })).toBeInTheDocument(); - }); - - it('renders the subheading', () => { - render(); - expect(screen.getByText('Your password has been successfully changed!', { selector: 'h5' })).toBeInTheDocument(); - }); - - it('renders the back to login button', () => { - render(); - const button = screen.getByRole('button', { name: 'Back to login' }); - expect(button).toBeInTheDocument(); - }) - - it('navigates to login page when back to login button is clicked', () => { - render(); - const button = screen.getByRole('button', { name: 'Back to login' }); - button.click(); - expect(mockPush).toHaveBeenCalledWith('/login'); - }); -}); \ No newline at end of file diff --git a/apps/frontend/test/components/ResetPasswordForm.test.tsx b/apps/frontend/test/components/ResetPasswordForm.test.tsx deleted file mode 100644 index a5b98f9d..00000000 --- a/apps/frontend/test/components/ResetPasswordForm.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { render, screen } from '../utils'; -import ResetPasswordForm from '@/app/components/ResetPasswordForm'; - -describe('ResetPasswordForm Component', () => { - it('renders the heading', () => { - render(); - expect(screen.getByText('Forgot your Password?', { selector: 'h1' })).toBeInTheDocument(); - }); - - it('renders the subheading', () => { - render(); - expect(screen.getByText(/please enter the email address/i, { selector: 'h5' })).toBeInTheDocument(); - }); - - it('renders the email input field', () => { - render(); - expect(screen.getByPlaceholderText('Placeholder')).toBeInTheDocument(); - }); - - it('renders the request reset link button', () => { - render(); - expect(screen.getByRole('button', { name: 'Request reset link' })).toBeInTheDocument(); - }); - - it('renders the back to login link', () => { - render(); - const link = screen.getByRole('link', { name: 'Back to login' }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', '#'); - }); -}); diff --git a/apps/frontend/test/components/ResetPasswordPage.test.tsx b/apps/frontend/test/components/ResetPasswordPage.test.tsx new file mode 100644 index 00000000..27cd92bf --- /dev/null +++ b/apps/frontend/test/components/ResetPasswordPage.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, waitFor } from '../utils'; +import userEvent from '@testing-library/user-event'; +import ResetPasswordPage from '@/app/reset-password/page'; + +const mockPush = jest.fn(); +const mockRouter = { push: mockPush, replace: jest.fn() }; +let searchParams = new URLSearchParams('email=jane@example.com&code=123456'); + +jest.mock('next/navigation', () => ({ + useRouter: () => mockRouter, + usePathname: () => '/reset-password', + useSearchParams: () => searchParams, +})); + +const mockResetPassword = jest.fn(); + +jest.mock('../../src/context/AuthContext', () => ({ + ...jest.requireActual('../../src/context/AuthContext'), + useAuth: () => ({ resetPassword: mockResetPassword }), +})); + +async function submitNewPassword(password = 'NewPassword1!') { + await userEvent.type(screen.getByPlaceholderText('Enter new password'), password); + await userEvent.type(screen.getByPlaceholderText('Retype password'), password); + await userEvent.click(screen.getByRole('button', { name: 'Reset Password' })); +} + +beforeEach(() => { + jest.clearAllMocks(); + searchParams = new URLSearchParams('email=jane@example.com&code=123456'); +}); + +describe('Reset Password Page', () => { + it('shows the confirmation only after the request succeeds', async () => { + mockResetPassword.mockResolvedValue(undefined); + render(); + + await submitNewPassword(); + + await waitFor(() => expect(screen.getByText('Password Changed')).toBeInTheDocument()); + expect(mockResetPassword).toHaveBeenCalledWith( + 'jane@example.com', + '123456', + 'NewPassword1!', + ); + }); + + it('does NOT claim success when the request fails', async () => { + // Regression guard: setSubmitted(true) used to live in `finally`, so users + // were told their password had changed when it had not. + mockResetPassword.mockRejectedValue(new Error('Invalid verification code')); + render(); + + await submitNewPassword(); + + await waitFor(() => + expect(screen.getByText('Invalid verification code')).toBeInTheDocument(), + ); + expect(screen.queryByText('Password Changed')).not.toBeInTheDocument(); + }); + + it('rejects a weak password without calling the API', async () => { + render(); + + await submitNewPassword('weak'); + + expect(mockResetPassword).not.toHaveBeenCalled(); + expect(screen.queryByText('Password Changed')).not.toBeInTheDocument(); + }); + + it('rejects mismatched passwords without calling the API', async () => { + render(); + + await userEvent.type(screen.getByPlaceholderText('Enter new password'), 'NewPassword1!'); + await userEvent.type(screen.getByPlaceholderText('Retype password'), 'Different1!'); + await userEvent.click(screen.getByRole('button', { name: 'Reset Password' })); + + expect(screen.getByText('Password does not match')).toBeInTheDocument(); + expect(mockResetPassword).not.toHaveBeenCalled(); + }); + + it.each([ + ['code=123456', 'missing email'], + ['email=jane@example.com', 'missing code'], + ['', 'missing both'], + ])('shows an expired-link state when the query string is incomplete (%s)', async (qs) => { + searchParams = new URLSearchParams(qs); + render(); + + expect(screen.getByText('Link expired')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Enter new password')).not.toBeInTheDocument(); + expect(mockResetPassword).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/test/context/AuthContext.test.tsx b/apps/frontend/test/context/AuthContext.test.tsx index 7b7ac2fa..862502b4 100644 --- a/apps/frontend/test/context/AuthContext.test.tsx +++ b/apps/frontend/test/context/AuthContext.test.tsx @@ -1,12 +1,14 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { AuthProvider, useAuth } from '@/context/AuthContext'; +import { STORAGE_KEYS } from '@/lib/authTokens'; +import { __resetRefreshStateForTests, onSessionExpired } from '@/lib/authClient'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** Build a minimal JWT whose payload contains the given claims. */ -function makeIdToken(claims: Record) { +/** Builds a JWT-shaped string whose payload contains the given claims. */ +function makeToken(claims: Record) { const payload = btoa(JSON.stringify(claims)) .replace(/\+/g, '-') .replace(/\//g, '_') @@ -14,125 +16,363 @@ function makeIdToken(claims: Record) { return `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`; } -const TEST_TOKENS = { - AccessToken: 'test-access-token', - IdToken: makeIdToken({ sub: 'sub-123', email: 'jane@example.com', name: 'Jane' }), +/** An access token that expires `seconds` from now. */ +function accessTokenExpiringIn(seconds: number) { + return makeToken({ sub: 'sub-123', exp: Math.floor(Date.now() / 1000) + seconds }); +} + +const ME = { + userId: 7, + cognitoSub: 'sub-123', + email: 'jane@example.com', + name: 'Jane Doe', + isAdmin: false, +}; + +const TOKENS = { + AccessToken: accessTokenExpiringIn(3600), + IdToken: makeToken({ sub: 'sub-123', email: 'jane@example.com' }), RefreshToken: 'test-refresh-token', }; -function mockFetch(body: unknown, ok = true) { - global.fetch = jest.fn().mockResolvedValue({ - ok, - statusText: 'Unauthorized', - json: jest.fn().mockResolvedValue(body), - } as unknown as Response); +interface RouteResponse { + status?: number; + body?: unknown; +} + +/** Routes fetch by URL substring so a test can stub several endpoints at once. */ +function mockRoutes(routes: Record) { + const fetchMock = jest.fn(async (url: string) => { + const match = Object.keys(routes).find((key) => url.includes(key)); + const { status = 200, body = {} } = match ? routes[match] : { status: 404, body: {} }; + return { + ok: status >= 200 && status < 300, + status, + statusText: 'Error', + json: async () => body, + } as unknown as Response; + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +function seedTokens(access = TOKENS.AccessToken) { + localStorage.setItem(STORAGE_KEYS.ACCESS, access); + localStorage.setItem(STORAGE_KEYS.ID, TOKENS.IdToken); + localStorage.setItem(STORAGE_KEYS.REFRESH, TOKENS.RefreshToken); } const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); +async function renderAuth() { + const view = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(view.result.current.isLoading).toBe(false)); + return view; +} + +beforeEach(() => { + localStorage.clear(); + __resetRefreshStateForTests(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -beforeEach(() => localStorage.clear()); -afterEach(() => jest.restoreAllMocks()); - describe('AuthProvider / useAuth', () => { it('throws when used outside AuthProvider', () => { - // suppress expected console.error from React jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => renderHook(() => useAuth())).toThrow('useAuth must be used inside AuthProvider'); }); - it('starts with no user and finishes loading', async () => { - const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.user).toBeNull(); - expect(result.current.isAuthenticated).toBe(false); - }); + describe('session bootstrap', () => { + it('makes no network call and settles unauthenticated when no tokens are stored', async () => { + const fetchMock = mockRoutes({}); - it('restores user from localStorage on mount', async () => { - localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); - const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.user).toMatchObject({ sub: 'sub-123', email: 'jane@example.com', name: 'Jane' }); - expect(result.current.isAuthenticated).toBe(true); - }); + const { result } = await renderAuth(); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('restores the session from GET /auth/me when tokens are present', async () => { + seedTokens(); + mockRoutes({ '/auth/me': { body: { ...ME, isAdmin: true } } }); + + const { result } = await renderAuth(); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user?.name).toBe('Jane Doe'); + expect(result.current.isAdmin).toBe(true); + }); - it('login stores tokens and sets user state', async () => { - mockFetch(TEST_TOKENS); - const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + it('clears tokens and stays signed out when /auth/me and refresh both fail', async () => { + seedTokens(); + mockRoutes({ + '/auth/me': { status: 401, body: { message: 'Authentication required' } }, + '/auth/refresh': { status: 401, body: { message: 'expired' } }, + }); - await act(async () => { - await result.current.login('jane@example.com', 'password123'); + const { result } = await renderAuth(); + + expect(result.current.isAuthenticated).toBe(false); + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); }); - expect(result.current.isAuthenticated).toBe(true); - expect(result.current.user).toMatchObject({ email: 'jane@example.com' }); - expect(localStorage.getItem('branch_access_token')).toBe('test-access-token'); - expect(localStorage.getItem('branch_refresh_token')).toBe('test-refresh-token'); + it('takes isAdmin from /auth/me, never from a token claim', async () => { + // The ID token lies; the server is the only authority. is_admin lives in + // Postgres and is not a JWT claim, so decoding could never produce it. + localStorage.setItem(STORAGE_KEYS.ACCESS, accessTokenExpiringIn(3600)); + localStorage.setItem( + STORAGE_KEYS.ID, + makeToken({ sub: 'sub-123', is_admin: true, 'cognito:groups': ['Admins'] }), + ); + localStorage.setItem(STORAGE_KEYS.REFRESH, 'r'); + mockRoutes({ '/auth/me': { body: { ...ME, isAdmin: false } } }); + + const { result } = await renderAuth(); + + expect(result.current.isAdmin).toBe(false); + }); }); - it('getAccessToken returns the stored access token', async () => { - mockFetch(TEST_TOKENS); - const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + describe('login', () => { + it('stores tokens and loads the user on success', async () => { + mockRoutes({ '/auth/login': { body: TOKENS }, '/auth/me': { body: ME } }); + + const { result } = await renderAuth(); + await act(async () => { + const outcome = await result.current.login('jane@example.com', 'pw'); + expect(outcome).toEqual({ status: 'authenticated' }); + }); + + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBe(TOKENS.AccessToken); + expect(localStorage.getItem(STORAGE_KEYS.REFRESH)).toBe(TOKENS.RefreshToken); + expect(result.current.isAuthenticated).toBe(true); + }); + + it('returns a challenge without writing anything to storage', async () => { + // Regression guard: this used to persist `undefined` for every token — + // literally the string "undefined" — and report success. + mockRoutes({ + '/auth/login': { + body: { ChallengeName: 'NEW_PASSWORD_REQUIRED', Session: 'sess-1' }, + }, + }); - await act(async () => { - await result.current.login('jane@example.com', 'password123'); + const { result } = await renderAuth(); + await act(async () => { + const outcome = await result.current.login('jane@example.com', 'pw'); + expect(outcome).toEqual({ + status: 'challenge', + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'sess-1', + email: 'jane@example.com', + }); + }); + + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('rejects a token-less success response instead of half-authenticating', async () => { + mockRoutes({ '/auth/login': { body: {} } }); + + const { result } = await renderAuth(); + await act(async () => { + await expect(result.current.login('jane@example.com', 'pw')).rejects.toThrow( + 'Login response did not include tokens', + ); + }); + + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('rejects a challenge that arrives without a session', async () => { + mockRoutes({ '/auth/login': { body: { ChallengeName: 'SMS_MFA' } } }); + + const { result } = await renderAuth(); + await act(async () => { + await expect(result.current.login('jane@example.com', 'pw')).rejects.toThrow( + 'Login challenge returned without a session', + ); + }); }); - expect(result.current.getAccessToken()).toBe('test-access-token'); + it('clears tokens when /auth/me fails right after signing in', async () => { + mockRoutes({ + '/auth/login': { body: TOKENS }, + '/auth/me': { status: 500, body: { message: 'boom' } }, + '/auth/refresh': { status: 401, body: {} }, + }); + + const { result } = await renderAuth(); + await act(async () => { + await expect(result.current.login('jane@example.com', 'pw')).rejects.toThrow( + /could not load your profile/i, + ); + }); + + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('surfaces the server message on bad credentials', async () => { + mockRoutes({ '/auth/login': { status: 401, body: { message: 'Invalid email or password' } } }); + + const { result } = await renderAuth(); + await act(async () => { + await expect(result.current.login('jane@example.com', 'nope')).rejects.toThrow( + 'Invalid email or password', + ); + }); + }); }); - it('logout clears user state and localStorage', async () => { - localStorage.setItem('branch_access_token', 'test-access-token'); - localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); - localStorage.setItem('branch_refresh_token', 'test-refresh-token'); - mockFetch({ success: true }); + describe('respondToChallenge', () => { + it('completes NEW_PASSWORD_REQUIRED and signs the user in', async () => { + mockRoutes({ '/auth/respond-challenge': { body: TOKENS }, '/auth/me': { body: ME } }); - const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + const { result } = await renderAuth(); + await act(async () => { + const outcome = await result.current.respondToChallenge({ + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'sess-1', + email: 'jane@example.com', + newPassword: 'NewPassword123!', + }); + expect(outcome).toEqual({ status: 'authenticated' }); + }); - await act(async () => { - await result.current.logout(); + expect(result.current.isAuthenticated).toBe(true); }); - expect(result.current.user).toBeNull(); - expect(result.current.isAuthenticated).toBe(false); - expect(localStorage.getItem('branch_access_token')).toBeNull(); + it('returns the next challenge when Cognito chains one', async () => { + mockRoutes({ + '/auth/respond-challenge': { + body: { ChallengeName: 'SOFTWARE_TOKEN_MFA', Session: 'sess-2' }, + }, + }); + + const { result } = await renderAuth(); + await act(async () => { + const outcome = await result.current.respondToChallenge({ + challengeName: 'NEW_PASSWORD_REQUIRED', + session: 'sess-1', + email: 'jane@example.com', + newPassword: 'NewPassword123!', + }); + expect(outcome).toMatchObject({ + status: 'challenge', + challengeName: 'SOFTWARE_TOKEN_MFA', + }); + }); + }); }); - it('logout still clears state even if the server call fails', async () => { - localStorage.setItem('branch_access_token', 'test-access-token'); - localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); - global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + describe('logout', () => { + it('clears state and storage', async () => { + seedTokens(); + mockRoutes({ '/auth/me': { body: ME }, '/auth/logout': { body: { message: 'ok' } } }); - const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + const { result } = await renderAuth(); + expect(result.current.isAuthenticated).toBe(true); - await act(async () => { - await result.current.logout(); + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.isAuthenticated).toBe(false); + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); + expect(localStorage.getItem(STORAGE_KEYS.REFRESH)).toBeNull(); }); - expect(result.current.user).toBeNull(); - expect(localStorage.getItem('branch_access_token')).toBeNull(); + it('clears state even when the server call fails', async () => { + seedTokens(); + mockRoutes({ + '/auth/me': { body: ME }, + '/auth/logout': { status: 500, body: { message: 'boom' } }, + }); + + const { result } = await renderAuth(); + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.isAuthenticated).toBe(false); + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); + }); }); - it('login throws on invalid credentials', async () => { - mockFetch({ message: 'Invalid credentials' }, false); - const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + describe('session lifetime', () => { + it('signs the user out when another module ends the session', async () => { + seedTokens(); + mockRoutes({ '/auth/me': { body: ME } }); + + const { result } = await renderAuth(); + expect(result.current.isAuthenticated).toBe(true); + + // Simulates a background request hitting an unrecoverable 401. + await act(async () => { + const { endSession } = await import('@/lib/authClient'); + endSession(); + }); + + expect(result.current.isAuthenticated).toBe(false); + }); + + it('subscribes exactly one session-expiry listener', async () => { + seedTokens(); + mockRoutes({ '/auth/me': { body: ME } }); + + await renderAuth(); + + let listenerCount = 0; + // onSessionExpired returns an unsubscribe; adding one more and firing + // endSession proves the registry is live rather than counting internals. + const unsubscribe = onSessionExpired(() => { + listenerCount += 1; + }); + const { endSession } = await import('@/lib/authClient'); + act(() => endSession()); + unsubscribe(); + + expect(listenerCount).toBe(1); + }); + + it('refreshes the access token before it expires', async () => { + jest.useFakeTimers(); + seedTokens(accessTokenExpiringIn(300)); // 5 minutes out + const fetchMock = mockRoutes({ + '/auth/me': { body: ME }, + '/auth/refresh': { + body: { AccessToken: accessTokenExpiringIn(3600), IdToken: TOKENS.IdToken }, + }, + }); - await expect( - act(async () => { - await result.current.login('bad@example.com', 'wrong'); - }), - ).rejects.toThrow('Invalid credentials'); + const view = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(view.result.current.isLoading).toBe(false)); + expect(view.result.current.isAuthenticated).toBe(true); - expect(result.current.isAuthenticated).toBe(false); + // Scheduled 2 minutes before expiry, i.e. ~3 minutes from now. + await act(async () => { + jest.advanceTimersByTime(4 * 60 * 1000); + }); + + const refreshCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes('/auth/refresh'), + ); + expect(refreshCalls).toHaveLength(1); + expect(view.result.current.isAuthenticated).toBe(true); + }); }); }); diff --git a/apps/frontend/test/lib/authClient.test.ts b/apps/frontend/test/lib/authClient.test.ts new file mode 100644 index 00000000..a7f22bae --- /dev/null +++ b/apps/frontend/test/lib/authClient.test.ts @@ -0,0 +1,269 @@ +import { ApiError } from '@/lib/api'; +import { + __resetRefreshStateForTests, + authedFetch, + endSession, + onSessionExpired, + refreshSession, +} from '@/lib/authClient'; +import { STORAGE_KEYS } from '@/lib/authTokens'; + +function makeToken(claims: Record) { + const payload = btoa(JSON.stringify(claims)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`; +} + +const VALID_ACCESS = makeToken({ sub: 's', exp: Math.floor(Date.now() / 1000) + 3600 }); +const EXPIRED_ACCESS = makeToken({ sub: 's', exp: Math.floor(Date.now() / 1000) - 60 }); +const NEW_ACCESS = makeToken({ sub: 's', exp: Math.floor(Date.now() / 1000) + 3600 }); + +function seed(access = VALID_ACCESS, refresh: string | null = 'refresh-token') { + localStorage.setItem(STORAGE_KEYS.ACCESS, access); + localStorage.setItem(STORAGE_KEYS.ID, makeToken({ sub: 's' })); + if (refresh) localStorage.setItem(STORAGE_KEYS.REFRESH, refresh); +} + +interface Reply { + status?: number; + body?: unknown; +} + +/** Queues per-URL replies; each key can supply a sequence of responses. */ +function mockFetch(plan: Record) { + const cursors: Record = {}; + const fetchMock = jest.fn(async (url: string, init?: RequestInit) => { + const key = Object.keys(plan).find((k) => url.includes(k)); + if (!key) throw new Error(`Unexpected fetch to ${url}`); + const index = Math.min(cursors[key] ?? 0, plan[key].length - 1); + cursors[key] = (cursors[key] ?? 0) + 1; + const { status = 200, body = {} } = plan[key][index]; + return { + ok: status >= 200 && status < 300, + status, + statusText: 'Error', + json: async () => body, + // Expose the request so tests can assert on the outgoing header. + __init: init, + } as unknown as Response; + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +function authHeaderOf(call: unknown[]): string | undefined { + const init = call[1] as RequestInit; + return (init.headers as Record)['Authorization']; +} + +function callsTo(fetchMock: jest.Mock, fragment: string) { + return fetchMock.mock.calls.filter(([url]) => String(url).includes(fragment)); +} + +beforeEach(() => { + localStorage.clear(); + __resetRefreshStateForTests(); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe('authedFetch', () => { + it('attaches the stored access token', async () => { + seed(); + const fetchMock = mockFetch({ '/projects': [{ body: [] }] }); + + await authedFetch('/projects'); + + expect(authHeaderOf(fetchMock.mock.calls[0])).toBe(`Bearer ${VALID_ACCESS}`); + }); + + it('ends the session immediately when no token is stored', async () => { + const expired = jest.fn(); + onSessionExpired(expired); + const fetchMock = mockFetch({ '/projects': [{ body: [] }] }); + + await expect(authedFetch('/projects')).rejects.toMatchObject({ status: 401 }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(expired).toHaveBeenCalled(); + }); + + it('refreshes pre-emptively when the stored token has already expired', async () => { + seed(EXPIRED_ACCESS); + const fetchMock = mockFetch({ + '/auth/refresh': [{ body: { AccessToken: NEW_ACCESS, IdToken: 'id' } }], + '/projects': [{ body: [] }], + }); + + await authedFetch('/projects'); + + // Refresh happens first — no wasted round trip on a guaranteed 401. + expect(String(fetchMock.mock.calls[0][0])).toContain('/auth/refresh'); + expect(authHeaderOf(fetchMock.mock.calls[1])).toBe(`Bearer ${NEW_ACCESS}`); + }); + + it('refreshes and retries once on a 401, using the new token', async () => { + seed(); + const fetchMock = mockFetch({ + '/projects': [{ status: 401, body: { message: 'expired' } }, { body: [] }], + '/auth/refresh': [{ body: { AccessToken: NEW_ACCESS, IdToken: 'id' } }], + }); + + await expect(authedFetch('/projects')).resolves.toEqual([]); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(authHeaderOf(callsTo(fetchMock, '/projects')[1])).toBe(`Bearer ${NEW_ACCESS}`); + }); + + it('does not retry twice when the retry also 401s', async () => { + seed(); + const fetchMock = mockFetch({ + '/projects': [{ status: 401, body: {} }, { status: 401, body: {} }], + '/auth/refresh': [{ body: { AccessToken: NEW_ACCESS, IdToken: 'id' } }], + }); + + await expect(authedFetch('/projects')).rejects.toMatchObject({ status: 401 }); + + expect(callsTo(fetchMock, '/auth/refresh')).toHaveLength(1); + expect(callsTo(fetchMock, '/projects')).toHaveLength(2); + }); + + it('ends the session when the refresh itself fails', async () => { + seed(); + const expired = jest.fn(); + onSessionExpired(expired); + mockFetch({ + '/projects': [{ status: 401, body: {} }], + '/auth/refresh': [{ status: 401, body: {} }], + }); + + await expect(authedFetch('/projects')).rejects.toMatchObject({ status: 401 }); + + expect(expired).toHaveBeenCalled(); + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); + }); + + it('does not attempt a refresh when retryOn401 is false', async () => { + seed(); + const fetchMock = mockFetch({ + '/auth/logout': [{ status: 401, body: {} }], + '/auth/refresh': [{ body: { AccessToken: NEW_ACCESS, IdToken: 'id' } }], + }); + + await expect( + authedFetch('/auth/logout', { method: 'POST', retryOn401: false }), + ).rejects.toMatchObject({ status: 401 }); + + expect(callsTo(fetchMock, '/auth/refresh')).toHaveLength(0); + }); + + it('propagates a non-401 error untouched', async () => { + seed(); + const fetchMock = mockFetch({ + '/projects': [{ status: 500, body: { message: 'boom' } }], + '/auth/refresh': [{ body: {} }], + }); + + await expect(authedFetch('/projects')).rejects.toThrow('boom'); + expect(callsTo(fetchMock, '/auth/refresh')).toHaveLength(0); + }); + + it('issues exactly one refresh for a burst of concurrent 401s', async () => { + seed(); + const fetchMock = mockFetch({ + '/projects': [ + { status: 401, body: {} }, + { status: 401, body: {} }, + { status: 401, body: {} }, + { status: 401, body: {} }, + { status: 401, body: {} }, + { body: [] }, + ], + '/auth/refresh': [{ body: { AccessToken: NEW_ACCESS, IdToken: 'id' } }], + }); + + await Promise.allSettled( + Array.from({ length: 5 }, () => authedFetch('/projects')), + ); + + expect(callsTo(fetchMock, '/auth/refresh')).toHaveLength(1); + }); +}); + +describe('refreshSession', () => { + it('returns false without calling the API when there is no refresh token', async () => { + seed(VALID_ACCESS, null); + const fetchMock = mockFetch({ '/auth/refresh': [{ body: {} }] }); + + await expect(refreshSession()).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('stores the new tokens without erasing the refresh token', async () => { + // Cognito does not re-issue a refresh token on REFRESH_TOKEN_AUTH, so the + // stored one must survive. + seed(); + mockFetch({ '/auth/refresh': [{ body: { AccessToken: NEW_ACCESS, IdToken: 'new-id' } }] }); + + await expect(refreshSession()).resolves.toBe(true); + + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBe(NEW_ACCESS); + expect(localStorage.getItem(STORAGE_KEYS.ID)).toBe('new-id'); + expect(localStorage.getItem(STORAGE_KEYS.REFRESH)).toBe('refresh-token'); + }); + + it('returns false when the response is missing tokens', async () => { + seed(); + mockFetch({ '/auth/refresh': [{ body: { AccessToken: NEW_ACCESS } }] }); + + await expect(refreshSession()).resolves.toBe(false); + }); +}); + +describe('endSession', () => { + it('clears tokens and notifies every listener', () => { + seed(); + const a = jest.fn(); + const b = jest.fn(); + onSessionExpired(a); + onSessionExpired(b); + + endSession(); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(STORAGE_KEYS.ACCESS)).toBeNull(); + expect(localStorage.getItem(STORAGE_KEYS.REFRESH)).toBeNull(); + }); + + it('stops notifying after unsubscribe', () => { + const listener = jest.fn(); + const unsubscribe = onSessionExpired(listener); + unsubscribe(); + + endSession(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('keeps notifying the remaining listeners when one throws', () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const survivor = jest.fn(); + onSessionExpired(() => { + throw new Error('listener blew up'); + }); + onSessionExpired(survivor); + + expect(() => endSession()).not.toThrow(); + expect(survivor).toHaveBeenCalled(); + }); +}); + +describe('ApiError', () => { + it('is an Error, so existing catch blocks keep working', () => { + const error = new ApiError('nope', 401, { message: 'nope' }); + expect(error).toBeInstanceOf(Error); + expect(error.status).toBe(401); + }); +}); diff --git a/apps/frontend/test/lib/routes.test.ts b/apps/frontend/test/lib/routes.test.ts new file mode 100644 index 00000000..29e2ab2a --- /dev/null +++ b/apps/frontend/test/lib/routes.test.ts @@ -0,0 +1,92 @@ +import { + LOGIN_PATH, + POST_LOGIN_PATH, + classifyRoute, + normalizePath, + requiresAdmin, + safeNextPath, +} from '@/lib/routes'; + +describe('normalizePath', () => { + it.each([ + ['/login', '/login'], + // next.config.ts sets trailingSlash: true, so production paths look like this. + ['/login/', '/login'], + ['/Expenses/', '/expenses'], + ['/', '/'], + ['', '/'], + ])('normalizes %p to %p', (input, expected) => { + expect(normalizePath(input)).toBe(expected); + }); +}); + +describe('classifyRoute', () => { + it('treats the root as the bootstrap route', () => { + expect(classifyRoute('/')).toBe('bootstrap'); + }); + + it.each(['/login', '/login/', '/forgot-password', '/reset-password'])( + 'treats %s as public', + (path) => { + expect(classifyRoute(path)).toBe('public'); + }, + ); + + it.each(['/dashboard', '/expenses/', '/projects/7', '/donors', '/accounts'])( + 'treats %s as protected', + (path) => { + expect(classifyRoute(path)).toBe('protected'); + }, + ); + + it('is protected-by-default for a route nobody has thought about yet', () => { + // This is the property that keeps the original bug from recurring: a new + // page under src/app/ is guarded without anyone opting in. + expect(classifyRoute('/some-brand-new-page')).toBe('protected'); + }); + + it('does not treat a lookalike prefix as public', () => { + expect(classifyRoute('/login-help')).toBe('protected'); + }); +}); + +describe('requiresAdmin', () => { + it.each(['/expenses', '/reports/', '/accounts', '/expenses/123'])( + 'requires admin for %s', + (path) => { + expect(requiresAdmin(path)).toBe(true); + }, + ); + + it.each(['/dashboard', '/donors', '/projects/7', '/reports-archive'])( + 'does not require admin for %s', + (path) => { + expect(requiresAdmin(path)).toBe(false); + }, + ); +}); + +describe('safeNextPath', () => { + it('accepts a same-origin path with a query string', () => { + expect(safeNextPath('/expenses?page=2')).toBe('/expenses?page=2'); + }); + + const unsafe: Array<[string | null, string]> = [ + ['//evil.example.com', 'protocol-relative URL'], + ['https://evil.example.com', 'absolute URL'], + ['http://evil.example.com', 'absolute URL'], + ['\\\\evil.example.com', 'backslash escape'], + ['/redirect?to=https://evil.example.com\\x', 'embedded backslash'], + ['expenses', 'relative path'], + [null, 'missing value'], + ['', 'empty value'], + ]; + + it.each(unsafe)('rejects %p (%s) and falls back to the dashboard', (raw) => { + expect(safeNextPath(raw)).toBe(POST_LOGIN_PATH); + }); + + it('honours an explicit fallback', () => { + expect(safeNextPath(null, LOGIN_PATH)).toBe(LOGIN_PATH); + }); +}); diff --git a/infrastructure/AGENTS.md b/infrastructure/AGENTS.md index 81b328f7..ccec998a 100644 --- a/infrastructure/AGENTS.md +++ b/infrastructure/AGENTS.md @@ -9,8 +9,8 @@ Common to all modules: Terraform **1.13.0** (`.terraform-version`, tfenv), state ### `aws/` (state key `aws/terraform.tfstate`) Application infra. Providers: AWS 6.14.1, Infisical. - `main.tf` — RDS PostgreSQL 17.6 (db.t3.micro), `branch_rds` db; creds from Infisical `/aws/rds`. -- `lambda.tf` — 6 Lambda functions (auth/donors/expenditures/projects/reports/users, Node 20.x, 256MB, 30s), IAM role (CloudWatch Logs), deployment S3 bucket. **`lifecycle` ignores `s3_key`** — code is deployed by CI (`lambda-deploy`), not Terraform. Env: `NODE_ENV`, `DB_*`. -- `cognito.tf` — user pool (email sign-in, auto-verify, 8-char password policy, advanced security, deletion protection) + public client (1h access/ID tokens, 30d refresh, no secret). **Manual step:** copy output pool/client IDs into Infisical `/aws/cognito/`. +- `lambda.tf` — 6 Lambda functions (auth/donors/expenditures/projects/reports/users, Node 20.x, 256MB, 30s), IAM role (CloudWatch Logs + pool-scoped `cognito-idp:AdminDeleteUser`/`AdminGetUser` for the registration-rollback path), deployment S3 bucket. **`lifecycle` ignores `s3_key` only** — code is deployed by CI (`lambda-deploy`), not Terraform. Env: `NODE_ENV`, `DB_*`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID`, `REPORTS_BUCKET_NAME`. **The `environment` block is authoritative:** any var set by hand in the console and not declared here is deleted on the next apply, which previously took out authentication across all six lambdas. `AWS_REGION` is Lambda-reserved and must stay absent. +- `cognito.tf` — user pool (email sign-in, auto-verify, 8-char password policy, `advanced_security_mode = AUDIT`, `mfa_configuration = OFF`, deletion protection) + public client (1h access/ID tokens, 30d refresh, no secret). Outputs `cognito_user_pool_id` / `cognito_client_id`; the lambdas get these from `lambda.tf` directly, no manual step. Infisical `/aws/cognito/` is still the source for the `COGNITO_*` GitHub Actions secrets used by `lambda-tests.yml` — keep it in sync if the pool is ever recreated. Threat protection is AUDIT rather than ENFORCED because every sign-in is proxied through the auth lambda, so adaptive auth would risk-score one shared ENI address. Enabling MFA later is a change here only; the backend already handles the challenges. - `api_gateway.tf` — REST API, one resource per lambda, method routing, `AWS_PROXY` integration, `prod` stage. - `s3.tf` — public-read reports bucket + versioned/encrypted lambda-deployments bucket. - `frontend_hosting.tf` — static frontend: private S3 bucket + CloudFront (OAC) with an SPA fallback (403/404 → `/index.html`) and an index-rewrite CloudFront Function. The Next.js app is exported (`output: 'export'`) and synced to S3 by the `frontend-deploy` workflow. diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index eab645c3..fa12d96a 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -46,6 +46,7 @@ No modules. | [aws_iam_role.lambda_role](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role) | resource | | [aws_iam_role_policy.ci_plan_state_lock](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ci_preview](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_cognito_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.ci_apply_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ci_plan_readonly](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.lambda_basic](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | @@ -86,9 +87,11 @@ No modules. | [ci\_apply\_role\_arn](#output\_ci\_apply\_role\_arn) | OIDC role for terraform-apply / lambda-deploy (write, production env only) | | [ci\_plan\_role\_arn](#output\_ci\_plan\_role\_arn) | OIDC role for terraform-plan (read-only) | | [ci\_preview\_role\_arn](#output\_ci\_preview\_role\_arn) | OIDC role for preview-env.yml (scoped write, preview env only) | +| [cognito\_client\_id](#output\_cognito\_client\_id) | Cognito User Pool Client ID (public client, generate\_secret = false) | | [cognito\_region](#output\_cognito\_region) | AWS Region for Cognito | | [cognito\_user\_pool\_arn](#output\_cognito\_user\_pool\_arn) | Cognito User Pool ARN | | [cognito\_user\_pool\_endpoint](#output\_cognito\_user\_pool\_endpoint) | Cognito User Pool Endpoint | +| [cognito\_user\_pool\_id](#output\_cognito\_user\_pool\_id) | Cognito User Pool ID (wired into the lambdas by lambda.tf) | | [frontend\_bucket](#output\_frontend\_bucket) | S3 bucket the frontend build is synced to | | [frontend\_cloudfront\_distribution\_id](#output\_frontend\_cloudfront\_distribution\_id) | CloudFront distribution id (for cache invalidation in CI) | | [frontend\_cloudfront\_domain](#output\_frontend\_cloudfront\_domain) | Public URL of the frontend | diff --git a/infrastructure/aws/api_gateway.tf b/infrastructure/aws/api_gateway.tf index 1fc95129..c146aa73 100644 --- a/infrastructure/aws/api_gateway.tf +++ b/infrastructure/aws/api_gateway.tf @@ -20,7 +20,7 @@ resource "aws_api_gateway_gateway_response" "cors" { response_parameters = { "gatewayresponse.header.Access-Control-Allow-Origin" = "'*'" "gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'" - "gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'" + "gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" } } diff --git a/infrastructure/aws/cognito.tf b/infrastructure/aws/cognito.tf index dc0323cd..a4b99858 100644 --- a/infrastructure/aws/cognito.tf +++ b/infrastructure/aws/cognito.tf @@ -54,11 +54,26 @@ resource "aws_cognito_user_pool" "branch_user_pool" { email_sending_account = "COGNITO_DEFAULT" } - # User pool add-ons + # AUDIT, not ENFORCED: every sign-in is proxied through the auth lambda, so all + # InitiateAuth calls arrive from a handful of Lambda ENI addresses. Under + # ENFORCED, adaptive authentication risk-scores that shared IP and can block or + # force MFA on legitimate users after unrelated failures by other users, and + # correct scoring needs client-side UserContextData the lambda cannot supply + # (enable_propagate_additional_user_context_data is false below). AUDIT keeps + # the risk telemetry without gating logins. ENFORCED also requires the Cognito + # Plus feature tier. user_pool_add_ons { - advanced_security_mode = "ENFORCED" + advanced_security_mode = "AUDIT" } + # MFA is off for now, stated explicitly rather than left to the default. + # Turning it on later is config-only on the backend: set "OPTIONAL" and add + # software_token_mfa_configuration { enabled = true }. POST + # /auth/respond-challenge already handles SOFTWARE_TOKEN_MFA / SMS_MFA / + # EMAIL_OTP / SELECT_MFA_TYPE, and POST /auth/login returns the challenge + # instead of hanging; only TOTP *enrollment* endpoints would need adding. + mfa_configuration = "OFF" + # Prevent accidental deletion deletion_protection = "ACTIVE" @@ -117,10 +132,26 @@ resource "aws_cognito_user_pool_client" "branch_client" { enable_propagate_additional_user_context_data = false } -# NOTE: To use these in lambdas, manually create secrets in Infisical at /aws/cognito/: -# - user_pool_id: Copy from terraform output cognito_user_pool_id -# - client_id: Copy from terraform output cognito_client_id -# - region: us-east-2 +# The lambdas get these IDs from Terraform directly -- see the `environment` +# block in lambda.tf. There is no manual console or Infisical step for the lambda +# runtime any more. +# +# The Infisical /aws/cognito folder is still the source for the COGNITO_* +# GitHub Actions secrets consumed by .github/workflows/lambda-tests.yml (see +# infrastructure/github/secrets.tf). infrastructure/github is a separate root +# module with separate state and cannot read this module's outputs without a +# terraform_remote_state data source, so keep it in sync with the outputs below +# if this pool is ever recreated. + +output "cognito_user_pool_id" { + value = aws_cognito_user_pool.branch_user_pool.id + description = "Cognito User Pool ID (wired into the lambdas by lambda.tf)" +} + +output "cognito_client_id" { + value = aws_cognito_user_pool_client.branch_client.id + description = "Cognito User Pool Client ID (public client, generate_secret = false)" +} output "cognito_user_pool_arn" { value = aws_cognito_user_pool.branch_user_pool.arn diff --git a/infrastructure/aws/lambda.tf b/infrastructure/aws/lambda.tf index 4aa36d0e..8bddf3d3 100644 --- a/infrastructure/aws/lambda.tf +++ b/infrastructure/aws/lambda.tf @@ -17,6 +17,35 @@ resource "aws_iam_role_policy_attachment" "lambda_basic" { policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } +# The auth lambda's registration-rollback path calls AdminDeleteUser when the +# branch.users write fails after a successful Cognito SignUp. Without this it +# fails AccessDeniedException and orphans a Cognito user with no DB row: that +# user can never log in (authenticate.ts finds no row) and re-registering +# returns 409 from Cognito. +# +# Every other Cognito API the auth lambda uses (SignUp, InitiateAuth, +# RespondToAuthChallenge, ConfirmSignUp, ResendConfirmationCode, +# ForgotPassword, ConfirmForgotPassword, GlobalSignOut) is modelled +# smithy.api#noAuth in the AWS SDK and needs no IAM at all -- which is also why +# local docker-compose auth works with no AWS credentials. +resource "aws_iam_role_policy" "lambda_cognito_admin" { + name = "branch-lambda-cognito-admin" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AuthLambdaUserPoolAdmin" + Effect = "Allow" + Action = [ + "cognito-idp:AdminDeleteUser", + "cognito-idp:AdminGetUser", + ] + Resource = aws_cognito_user_pool.branch_user_pool.arn + }] + }) +} + # Get AWS account ID for unique bucket naming data "aws_caller_identity" "current" {} @@ -95,6 +124,16 @@ resource "aws_lambda_function" "functions" { ignore_changes = [s3_key] } + # This block is AUTHORITATIVE, and is deliberately NOT in ignore_changes. + # The Cognito IDs used to exist only as hand-set console values outside + # Terraform state, so any `terraform apply` of this module deleted them and + # shared/lambda-auth/src/authenticate.ts then failed to build a verifier on + # every authenticated request in all six lambdas -- surfacing as blanket 401s + # rather than a loud error, because the throw happens inside a try block. + # Anything a lambda reads from process.env must be listed here. + # + # AWS_REGION is intentionally absent: it is a Lambda reserved key that the + # runtime provides, and the handlers already default to us-east-2. environment { variables = { NODE_ENV = "production" @@ -103,6 +142,16 @@ resource "aws_lambda_function" "functions" { DB_PASSWORD = data.infisical_secrets.rds_folder.secrets["password"].value DB_PORT = try(data.infisical_secrets.rds_folder.secrets["db_port"].value, "5432") DB_NAME = try(data.infisical_secrets.rds_folder.secrets["db_name"].value, aws_db_instance.branch_rds.db_name) + + # Not secrets: the user pool ID is public, and the app client is created + # with generate_secret = false, so there is no SECRET_HASH to protect. + COGNITO_USER_POOL_ID = aws_cognito_user_pool.branch_user_pool.id + COGNITO_CLIENT_ID = aws_cognito_user_pool_client.branch_client.id + + # Read by lambdas/reports/{handler,report-service}.ts. Previously hand-set + # on branch-reports only; listed here so this authoritative block does not + # wipe it. Harmless on the other five functions. + REPORTS_BUCKET_NAME = aws_s3_bucket.reports_bucket.id } } } \ No newline at end of file diff --git a/shared/lambda-auth/jest.config.js b/shared/lambda-auth/jest.config.js new file mode 100644 index 00000000..37b24d51 --- /dev/null +++ b/shared/lambda-auth/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], +}; diff --git a/shared/lambda-auth/package-lock.json b/shared/lambda-auth/package-lock.json index e2aa129b..db4e4e9a 100644 --- a/shared/lambda-auth/package-lock.json +++ b/shared/lambda-auth/package-lock.json @@ -11,25 +11,4238 @@ "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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "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/@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.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "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/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.42", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", "dev": true, "dependencies": { - "undici-types": "~6.21.0" + "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.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "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/aws-jwt-verify": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-5.2.1.tgz", + "integrity": "sha512-J+buA4M+qvQDk58WXFBLkDodkEX3DDL1ac5XFQPW3opxAsaLXYu5hYnlSHsaBRDBUXBAn695kE/cw/mdyJKwJg==", + "engines": { + "node": ">=18.0.0" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", + "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.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "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.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "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.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "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.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "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.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "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.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "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.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "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/aws-jwt-verify": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-5.2.1.tgz", - "integrity": "sha512-J+buA4M+qvQDk58WXFBLkDodkEX3DDL1ac5XFQPW3opxAsaLXYu5hYnlSHsaBRDBUXBAn695kE/cw/mdyJKwJg==", + "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": ">=18.0.0" + "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.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "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": { @@ -45,11 +4258,362 @@ "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 + }, + "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.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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-auth/package.json b/shared/lambda-auth/package.json index 39a0baaa..879e1cba 100644 --- a/shared/lambda-auth/package.json +++ b/shared/lambda-auth/package.json @@ -5,13 +5,18 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc" + "build": "tsc", + "test": "jest" }, "dependencies": { "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" } } diff --git a/shared/lambda-auth/src/authenticate.ts b/shared/lambda-auth/src/authenticate.ts index 560855f6..f832ee8e 100644 --- a/shared/lambda-auth/src/authenticate.ts +++ b/shared/lambda-auth/src/authenticate.ts @@ -7,21 +7,26 @@ interface QueryableDb { selectFrom(table: any): any; } -const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; -const COGNITO_CLIENT_ID = - process.env.COGNITO_CLIENT_ID || process.env.COGNITO_APP_CLIENT_ID || ''; - let verifier: any = null; +// env is read lazily rather than at module scope so that a missing +// COGNITO_USER_POOL_ID does not poison import of this module, and so tests can +// vary the environment with jest.resetModules(). function getVerifier() { if (!verifier) { - if (!COGNITO_USER_POOL_ID) { + const userPoolId = process.env.COGNITO_USER_POOL_ID || ''; + const clientId = + process.env.COGNITO_CLIENT_ID || process.env.COGNITO_APP_CLIENT_ID || ''; + if (!userPoolId) { throw new Error('COGNITO_USER_POOL_ID environment variable is not set'); } verifier = CognitoJwtVerifier.create({ - userPoolId: COGNITO_USER_POOL_ID, + userPoolId, tokenUse: 'access', - clientId: COGNITO_CLIENT_ID || null, + // null disables the audience check. It is only reached when neither + // COGNITO_CLIENT_ID nor COGNITO_APP_CLIENT_ID is set, which Terraform now + // prevents in every deployed environment (infrastructure/aws/lambda.tf). + clientId: clientId || null, }); } return verifier; @@ -66,13 +71,14 @@ export async function authenticateRequest( userId: dbUser.user_id, email: payload.email as string | undefined, isAdmin: dbUser.is_admin === true, + // Informational only. We deliberately do NOT promote on a Cognito + // "Admins" group: branch.users.is_admin is the single source of truth. + // A second source would make demotion via PATCH /users/{userId} silently + // ineffective, nothing in this codebase writes group membership, and no + // aws_cognito_user_group is defined in infrastructure/aws/cognito.tf. cognitoGroups: payload['cognito:groups'] as string[] | undefined, }; - if (user.cognitoGroups?.includes('Admins')) { - user.isAdmin = true; - } - return { user, isAuthenticated: true }; } catch (error) { console.error('Token verification failed:', error); diff --git a/shared/lambda-auth/test/authenticate.test.ts b/shared/lambda-auth/test/authenticate.test.ts new file mode 100644 index 00000000..2b9aa821 --- /dev/null +++ b/shared/lambda-auth/test/authenticate.test.ts @@ -0,0 +1,230 @@ +const mockVerify = jest.fn(); +const mockCreate = jest.fn(() => ({ verify: mockVerify })); + +jest.mock('aws-jwt-verify', () => ({ + CognitoJwtVerifier: { + create: (...args: unknown[]) => mockCreate(...(args as [])), + }, +})); + +/** Minimal Kysely-shaped stub: selectFrom().where().selectAll().executeTakeFirst() */ +function makeDb(row: unknown) { + const executeTakeFirst = jest.fn().mockResolvedValue(row); + return { + db: { + selectFrom: () => ({ + where: () => ({ selectAll: () => ({ executeTakeFirst }) }), + }), + }, + executeTakeFirst, + }; +} + +function bearerEvent(token: string) { + return { headers: { Authorization: `Bearer ${token}` } }; +} + +// The module memoizes its verifier and reads process.env lazily, so each test +// gets a fresh module registry. +async function loadModule() { + let mod: typeof import('../src/authenticate'); + await jest.isolateModulesAsync(async () => { + mod = await import('../src/authenticate'); + }); + return mod!; +} + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...ORIGINAL_ENV, + COGNITO_USER_POOL_ID: 'us-east-2_test', + COGNITO_CLIENT_ID: 'client-abc', + }; + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); +}); + +describe('extractToken', () => { + it('strips a Bearer prefix', async () => { + const { extractToken } = await loadModule(); + expect(extractToken(bearerEvent('tok123'))).toBe('tok123'); + }); + + it('accepts a lowercase authorization header', async () => { + const { extractToken } = await loadModule(); + expect(extractToken({ headers: { authorization: 'Bearer tok123' } })).toBe('tok123'); + }); + + it('is case-insensitive on the Bearer scheme', async () => { + const { extractToken } = await loadModule(); + expect(extractToken({ headers: { Authorization: 'bearer tok123' } })).toBe('tok123'); + }); + + it('returns a bare token unchanged when no scheme is present', async () => { + const { extractToken } = await loadModule(); + expect(extractToken({ headers: { Authorization: 'tok123' } })).toBe('tok123'); + }); + + it('returns the whole value for a malformed 3-part header (documents current behaviour)', async () => { + const { extractToken } = await loadModule(); + expect(extractToken({ headers: { Authorization: 'Bearer a b' } })).toBe('Bearer a b'); + }); + + it('returns null when the header is absent', async () => { + const { extractToken } = await loadModule(); + expect(extractToken({ headers: {} })).toBeNull(); + expect(extractToken({})).toBeNull(); + }); +}); + +describe('authenticateRequest', () => { + it('returns unauthenticated without verifying when no token is present', async () => { + const { authenticateRequest } = await loadModule(); + const { db } = makeDb(undefined); + + await expect(authenticateRequest(db, { headers: {} })).resolves.toEqual({ + isAuthenticated: false, + }); + expect(mockVerify).not.toHaveBeenCalled(); + }); + + it('returns unauthenticated when verification rejects', async () => { + mockVerify.mockRejectedValue(new Error('expired')); + const { authenticateRequest } = await loadModule(); + const { db, executeTakeFirst } = makeDb(undefined); + + await expect(authenticateRequest(db, bearerEvent('bad'))).resolves.toEqual({ + isAuthenticated: false, + }); + expect(executeTakeFirst).not.toHaveBeenCalled(); + }); + + it('returns unauthenticated when the token is valid but no branch.users row matches', async () => { + mockVerify.mockResolvedValue({ sub: 'orphan-sub' }); + const { authenticateRequest } = await loadModule(); + const { db } = makeDb(undefined); + + await expect(authenticateRequest(db, bearerEvent('good'))).resolves.toEqual({ + isAuthenticated: false, + }); + }); + + it('builds the auth context from the DB row', async () => { + mockVerify.mockResolvedValue({ sub: 'sub-1', email: 'a@b.com' }); + const { authenticateRequest } = await loadModule(); + const { db } = makeDb({ user_id: 7, is_admin: true }); + + await expect(authenticateRequest(db, bearerEvent('good'))).resolves.toEqual({ + isAuthenticated: true, + user: { + cognitoSub: 'sub-1', + userId: 7, + email: 'a@b.com', + isAdmin: true, + cognitoGroups: undefined, + }, + }); + }); + + it.each([[false], [null], [undefined], ['true']])( + 'treats is_admin %p as not-admin (strict === true only)', + async (isAdminValue) => { + mockVerify.mockResolvedValue({ sub: 'sub-1' }); + const { authenticateRequest } = await loadModule(); + const { db } = makeDb({ user_id: 7, is_admin: isAdminValue }); + + const ctx = await authenticateRequest(db, bearerEvent('good')); + expect(ctx.user?.isAdmin).toBe(false); + }, + ); + + it('does NOT promote a member of the Cognito "Admins" group to admin', async () => { + // Regression guard. branch.users.is_admin is the single source of truth; + // promoting on a group would make demotion via PATCH /users/{userId} + // silently ineffective. cognitoGroups stays populated but informational. + mockVerify.mockResolvedValue({ sub: 'sub-1', 'cognito:groups': ['Admins'] }); + const { authenticateRequest } = await loadModule(); + const { db } = makeDb({ user_id: 7, is_admin: false }); + + const ctx = await authenticateRequest(db, bearerEvent('good')); + expect(ctx.isAuthenticated).toBe(true); + expect(ctx.user?.isAdmin).toBe(false); + expect(ctx.user?.cognitoGroups).toEqual(['Admins']); + }); + + it('verifies with tokenUse "access" and the configured client id', async () => { + mockVerify.mockResolvedValue({ sub: 'sub-1' }); + const { authenticateRequest } = await loadModule(); + const { db } = makeDb({ user_id: 7, is_admin: false }); + + await authenticateRequest(db, bearerEvent('good')); + expect(mockCreate).toHaveBeenCalledWith({ + userPoolId: 'us-east-2_test', + tokenUse: 'access', + clientId: 'client-abc', + }); + }); + + it('falls back to COGNITO_APP_CLIENT_ID when COGNITO_CLIENT_ID is unset', async () => { + delete process.env.COGNITO_CLIENT_ID; + process.env.COGNITO_APP_CLIENT_ID = 'legacy-client'; + mockVerify.mockResolvedValue({ sub: 'sub-1' }); + const { authenticateRequest } = await loadModule(); + const { db } = makeDb({ user_id: 7, is_admin: false }); + + await authenticateRequest(db, bearerEvent('good')); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ clientId: 'legacy-client' }), + ); + }); + + it('disables the audience check when no client id is configured', async () => { + delete process.env.COGNITO_CLIENT_ID; + delete process.env.COGNITO_APP_CLIENT_ID; + mockVerify.mockResolvedValue({ sub: 'sub-1' }); + const { authenticateRequest } = await loadModule(); + const { db } = makeDb({ user_id: 7, is_admin: false }); + + await authenticateRequest(db, bearerEvent('good')); + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ clientId: null })); + }); + + it('degrades to unauthenticated (not a throw) when COGNITO_USER_POOL_ID is unset', async () => { + // This is why a missing env var manifests as blanket silent 401s across all + // six lambdas rather than a loud 500: getVerifier() throws inside the try. + delete process.env.COGNITO_USER_POOL_ID; + const { authenticateRequest } = await loadModule(); + const { db } = makeDb({ user_id: 7, is_admin: true }); + + await expect(authenticateRequest(db, bearerEvent('good'))).resolves.toEqual({ + isAuthenticated: false, + }); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('returns unauthenticated when the database query throws', async () => { + mockVerify.mockResolvedValue({ sub: 'sub-1' }); + const { authenticateRequest } = await loadModule(); + const db = { + selectFrom: () => ({ + where: () => ({ + selectAll: () => ({ + executeTakeFirst: jest.fn().mockRejectedValue(new Error('db down')), + }), + }), + }), + }; + + await expect(authenticateRequest(db, bearerEvent('good'))).resolves.toEqual({ + isAuthenticated: false, + }); + }); +}); diff --git a/shared/lambda-auth/test/authorize.test.ts b/shared/lambda-auth/test/authorize.test.ts new file mode 100644 index 00000000..f09adfb1 --- /dev/null +++ b/shared/lambda-auth/test/authorize.test.ts @@ -0,0 +1,132 @@ +import { checkAuthorization } from '../src/authorize'; +import type { AccessLevel, AuthContext } from '../src/types'; + +const anonymous: AuthContext = { isAuthenticated: false }; + +const nonAdmin: AuthContext = { + isAuthenticated: true, + user: { cognitoSub: 'sub-3', userId: 3, isAdmin: false }, +}; + +const admin: AuthContext = { + isAuthenticated: true, + user: { cognitoSub: 'sub-1', userId: 1, isAdmin: true }, +}; + +describe('checkAuthorization', () => { + describe('PUBLIC', () => { + it('allows anonymous callers', () => { + expect(checkAuthorization(anonymous, 'PUBLIC')).toEqual({ allowed: true }); + }); + + it('allows authenticated callers', () => { + expect(checkAuthorization(nonAdmin, 'PUBLIC')).toEqual({ allowed: true }); + }); + }); + + describe('anonymous callers', () => { + const gated: AccessLevel[] = ['AUTHENTICATED', 'ADMIN', 'SELF', 'ADMIN_OR_SELF']; + + it.each(gated)('denies %s with "Authentication required"', (level) => { + expect(checkAuthorization(anonymous, level, 3)).toEqual({ + allowed: false, + reason: 'Authentication required', + }); + }); + + it('denies a context flagged authenticated but carrying no user', () => { + const malformed = { isAuthenticated: true } as AuthContext; + expect(checkAuthorization(malformed, 'AUTHENTICATED').allowed).toBe(false); + }); + }); + + describe('AUTHENTICATED', () => { + it('allows any signed-in user', () => { + expect(checkAuthorization(nonAdmin, 'AUTHENTICATED')).toEqual({ allowed: true }); + }); + }); + + describe('ADMIN', () => { + it('allows admins', () => { + expect(checkAuthorization(admin, 'ADMIN')).toEqual({ allowed: true }); + }); + + it('denies non-admins', () => { + expect(checkAuthorization(nonAdmin, 'ADMIN')).toEqual({ + allowed: false, + reason: 'Admin access required', + }); + }); + }); + + describe('SELF', () => { + it('allows a user acting on their own id', () => { + expect(checkAuthorization(nonAdmin, 'SELF', 3)).toEqual({ allowed: true }); + }); + + it('coerces a string resourceUserId, so "3" matches userId 3', () => { + expect(checkAuthorization(nonAdmin, 'SELF', '3')).toEqual({ allowed: true }); + }); + + it('denies a user acting on someone else', () => { + expect(checkAuthorization(nonAdmin, 'SELF', 4)).toEqual({ + allowed: false, + reason: 'Can only access own resources', + }); + }); + + it('does NOT let an admin through — SELF means self, even for admins', () => { + expect(checkAuthorization(admin, 'SELF', 4).allowed).toBe(false); + }); + + it('denies when resourceUserId is omitted', () => { + expect(checkAuthorization(nonAdmin, 'SELF')).toEqual({ + allowed: false, + reason: 'Resource user ID required for SELF access check', + }); + }); + + it('treats resourceUserId 0 as missing, because the guard is falsy-based', () => { + // user_id is SERIAL and starts at 1, so 0 is unreachable today. This pins + // current behaviour: 0 takes the "required" branch rather than comparing. + expect(checkAuthorization(nonAdmin, 'SELF', 0).reason).toBe( + 'Resource user ID required for SELF access check', + ); + }); + + it('denies a non-numeric resourceUserId (NaN never equals userId)', () => { + expect(checkAuthorization(nonAdmin, 'SELF', 'abc').allowed).toBe(false); + }); + }); + + describe('ADMIN_OR_SELF', () => { + it('allows the owner', () => { + expect(checkAuthorization(nonAdmin, 'ADMIN_OR_SELF', 3)).toEqual({ allowed: true }); + }); + + it('allows an admin acting on someone else', () => { + expect(checkAuthorization(admin, 'ADMIN_OR_SELF', 99)).toEqual({ allowed: true }); + }); + + it('denies a non-admin acting on someone else', () => { + expect(checkAuthorization(nonAdmin, 'ADMIN_OR_SELF', 4)).toEqual({ + allowed: false, + reason: 'Admin access or resource ownership required', + }); + }); + + it('denies when resourceUserId is omitted, even for an admin', () => { + expect(checkAuthorization(admin, 'ADMIN_OR_SELF')).toEqual({ + allowed: false, + reason: 'Resource user ID required for ADMIN_OR_SELF access check', + }); + }); + }); + + it('denies an unrecognised access level', () => { + expect(checkAuthorization(admin, 'SUPERUSER' as AccessLevel)).toEqual({ + allowed: false, + reason: 'Unknown access level', + }); + }); +}); diff --git a/shared/types/db-types.d.ts b/shared/types/db-types.d.ts index 54febc0c..a1ffe4ed 100644 --- a/shared/types/db-types.d.ts +++ b/shared/types/db-types.d.ts @@ -75,8 +75,8 @@ export interface BranchReports { object_url: string; project_id: number; report_id: Generated; + report_type: Generated; title: string; - report_type: string; } export interface BranchUsers {