diff --git a/.github/actions/build-shared-packages/action.yml b/.github/actions/build-shared-packages/action.yml new file mode 100644 index 00000000..883e622a --- /dev/null +++ b/.github/actions/build-shared-packages/action.yml @@ -0,0 +1,27 @@ +name: Build shared lambda packages +description: > + Install and build the shared packages the lambdas consume as file: + dependencies. Both compile to a gitignored dist/, so anything that typechecks, + tests, or bundles a lambda has to run this first or the file: path resolves to + a package with no dist. + + Single source of truth on purpose: this logic lived in lambda-tests, + lambda-deploy and preview-env separately, and adding @branch/lambda-http + updated only the first two -- so preview deploys failed at esbuild with + "Could not resolve @branch/lambda-http" while the other two were green. + + Requires actions/checkout to have run. + +runs: + using: composite + steps: + # Order matters: lambda-http declares lambda-auth as file:../lambda-auth + # and compiles against its dist. + - name: Build shared packages + shell: bash + run: | + set -euo pipefail + npm ci --prefix shared/lambda-auth --no-audit --no-fund + npm run build --prefix shared/lambda-auth + npm ci --prefix shared/lambda-http --no-audit --no-fund + npm run build --prefix shared/lambda-http diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index 3db01586..72090316 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -126,11 +126,8 @@ jobs: with: node-version: '20' - # Order matters: lambda-http consumes lambda-auth's dist. - name: Build shared packages - run: | - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + uses: ./.github/actions/build-shared-packages - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index d5f050f6..959ec366 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -56,11 +56,8 @@ jobs: run: npm ci --no-audit --no-fund && npm run migrate && npm run seed env: DATABASE_URL: postgres://branch_dev:password@localhost:5432/branch_db - # Order matters: lambda-http consumes lambda-auth's dist. - name: Build shared packages - run: | - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + uses: ./.github/actions/build-shared-packages - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps @@ -266,11 +263,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - # Order matters: lambda-http consumes lambda-auth's dist. - name: Build shared packages - run: | - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + uses: ./.github/actions/build-shared-packages - name: Run tests run: npm test --prefix shared/lambda-http diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 69e53e61..a5e37da9 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -212,11 +212,14 @@ jobs: echo "lambdas=$(echo $lambdas | xargs)" >> "$GITHUB_OUTPUT" echo "frontend=$frontend" >> "$GITHUB_OUTPUT" + - name: Build shared packages + if: steps.detect.outputs.lambdas != '' + uses: ./.github/actions/build-shared-packages + - name: Build + deploy lambdas if: steps.detect.outputs.lambdas != '' run: | set -euo pipefail - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth for svc in ${{ steps.detect.outputs.lambdas }}; do echo "::group::lambda $svc" ( cd "apps/backend/lambdas/$svc" && npm ci --legacy-peer-deps && npm run package ) diff --git a/apps/backend/lambdas/auth/Dockerfile b/apps/backend/lambdas/auth/Dockerfile index 3b62f649..5a8a986f 100644 --- a/apps/backend/lambdas/auth/Dockerfile +++ b/apps/backend/lambdas/auth/Dockerfile @@ -11,6 +11,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app # Copy package files diff --git a/apps/backend/lambdas/auth/README.md b/apps/backend/lambdas/auth/README.md index e08505a3..90ba6bee 100644 --- a/apps/backend/lambdas/auth/README.md +++ b/apps/backend/lambdas/auth/README.md @@ -8,21 +8,21 @@ Lambda for auth handler. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | -| POST | /register | | -| POST | /login | | -| POST | /respond-challenge | | -| POST | /refresh | | -| GET | /me | | -| POST | /verify-email | | -| POST | /resend-code | | -| POST | /logout | | -| POST | /forgot-password | | -| POST | /reset-password | | -| POST | /mfa-setup | | -| POST | /mfa-verify | | -| POST | /mfa-disable | | -| GET | /mfa-status | | +| GET | /auth/health | Health check | +| POST | /auth/register | | +| POST | /auth/login | | +| POST | /auth/respond-challenge | | +| POST | /auth/refresh | | +| GET | /auth/me | | +| POST | /auth/verify-email | | +| POST | /auth/resend-code | | +| POST | /auth/logout | | +| POST | /auth/forgot-password | | +| POST | /auth/reset-password | | +| POST | /auth/mfa-setup | | +| POST | /auth/mfa-verify | | +| POST | /auth/mfa-disable | | +| GET | /auth/mfa-status | | ## Setup diff --git a/apps/backend/lambdas/auth/controllers/auth.ts b/apps/backend/lambdas/auth/controllers/auth.ts new file mode 100644 index 00000000..1b8d4144 --- /dev/null +++ b/apps/backend/lambdas/auth/controllers/auth.ts @@ -0,0 +1,257 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + InitiateAuthCommand, + InitiateAuthCommandInput, + RespondToAuthChallengeCommand, + GlobalSignOutCommand, + GlobalSignOutCommandInput, + ChallengeNameType, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json, parseBody } from '@branch/lambda-http'; +import { authenticateRequest } from '../auth'; +import db from '../db'; +import { + cognitoClient, + USER_POOL_CLIENT_ID, + CHALLENGE_SPECS, + authResultResponse, + challengeResponse, + mapCognitoAuthError, + validatePassword, +} from '../services/cognito'; + +/** + * POST /login + * + * Uses USER_PASSWORD_AUTH rather than SRP. The browser already posts the + * plaintext password to this endpoint over TLS, so server-side SRP adds no + * confidentiality -- and unlike the SRP library, the SDK hands back the + * challenge Session as an opaque string that survives across invocations, + * which is what makes a stateless POST /respond-challenge possible. + * + * Every branch returns. An unrecognised ChallengeName is passed to the client + * as a value rather than silently never resolving a promise, which is how the + * previous callback-based implementation hung until the 30s lambda timeout. + */ +export async function handleLogin(event: any): Promise { + const body = parseBody(event); + if (!body) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { email, password } = body; + if (!email || !password) { + return json(400, { message: 'email and password are required' }); + } + + // Registration stores email.toLowerCase(), so sign-in must match. + const username = String(email).toLowerCase(); + + const params: InitiateAuthCommandInput = { + AuthFlow: 'USER_PASSWORD_AUTH', + ClientId: USER_POOL_CLIENT_ID, + // No SECRET_HASH: the app client is created with generate_secret = false. + AuthParameters: { USERNAME: username, PASSWORD: String(password) }, + }; + + try { + const response = await cognitoClient.send(new InitiateAuthCommand(params)); + + if (response.AuthenticationResult) { + return authResultResponse(response.AuthenticationResult); + } + + if (response.ChallengeName) { + // MFA_SETUP cannot be answered by RespondToAuthChallenge alone -- it needs + // AssociateSoftwareToken/VerifySoftwareToken enrollment, which is not + // built yet. Return the Session anyway so a future enrollment endpoint can + // resume without forcing a fresh sign-in. + if (response.ChallengeName === 'MFA_SETUP') { + return json(403, { + ChallengeName: response.ChallengeName, + Session: response.Session, + message: 'MFA enrollment is required but not yet supported', + }); + } + return challengeResponse(response); + } + + return json(500, { message: 'Unexpected response from authentication service' }); + } catch (error: any) { + return mapCognitoAuthError(error, 'login'); + } +} + +/** + * POST /respond-challenge + * + * Answers whatever POST /login returned, using the opaque Session string. + * Responses chain: a challenge may be followed by another challenge (the usual + * NEW_PASSWORD_REQUIRED then TOTP-enrollment path), so the caller must branch on + * the response the same way it branches on /login. + */ +export async function handleRespondChallenge(event: any): Promise { + const body = parseBody(event); + if (!body) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { challengeName, session, email } = body; + if (!challengeName || !session || !email) { + return json(400, { + message: 'challengeName, session, and email are required', + }); + } + + const spec = CHALLENGE_SPECS[String(challengeName)]; + if (!spec) { + return json(400, { + message: `Unsupported challenge: ${challengeName}`, + supported: Object.keys(CHALLENGE_SPECS), + }); + } + + for (const field of spec.required) { + if (!body[field]) { + return json(400, { message: `${field} is required for ${challengeName}` }); + } + } + + if (challengeName === 'NEW_PASSWORD_REQUIRED') { + const passwordError = validatePassword(body.newPassword); + if (passwordError) { + return json(400, { message: passwordError }); + } + } + + try { + const response = await cognitoClient.send( + new RespondToAuthChallengeCommand({ + ClientId: USER_POOL_CLIENT_ID, + ChallengeName: challengeName as ChallengeNameType, + Session: String(session), + ChallengeResponses: spec.build(body, String(email).toLowerCase()), + }), + ); + + if (response.AuthenticationResult) { + return authResultResponse(response.AuthenticationResult); + } + if (response.ChallengeName) { + return challengeResponse(response); + } + return json(500, { message: 'Unexpected response from authentication service' }); + } catch (error: any) { + return mapCognitoAuthError(error, 'challenge'); + } +} + +/** + * POST /refresh + * + * Exchanges a refresh token for a new access and ID token. Cognito does NOT + * return a new refresh token here (no rotation is configured), so the client + * must keep the one it already stored until it expires. + */ +export async function handleRefresh(event: any): Promise { + const body = parseBody(event); + if (!body) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { refreshToken } = body; + if (!refreshToken) { + return json(400, { message: 'refreshToken is required' }); + } + + try { + const response = await cognitoClient.send( + new InitiateAuthCommand({ + AuthFlow: 'REFRESH_TOKEN_AUTH', + ClientId: USER_POOL_CLIENT_ID, + AuthParameters: { REFRESH_TOKEN: String(refreshToken) }, + }), + ); + + if (!response.AuthenticationResult) { + return json(401, { message: 'Refresh token is invalid or expired' }); + } + return authResultResponse(response.AuthenticationResult); + } catch (error: any) { + return mapCognitoAuthError(error, 'refresh'); + } +} + +/** + * GET /me -- the canonical session bootstrap endpoint. + * + * Everything is read from Postgres rather than the token, for two reasons: a + * Cognito *access* token carries sub/scope/client_id/token_use but neither email + * nor name, and is_admin exists only in branch.users -- there is no + * pre-token-generation trigger, so it is not a JWT claim. This endpoint is the + * only way the frontend can learn whether the caller is an admin. + */ +export async function handleMe(event: any): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const me = await db + .selectFrom('branch.users') + .where('cognito_sub', '=', authContext.user.cognitoSub) + .select(['user_id', 'cognito_sub', 'email', 'name', 'is_admin', 'profile_image']) + .executeTakeFirst(); + + // Defensive: authenticateRequest already rejects a token whose sub has no row, + // so this is unreachable today. Kept so a future refactor cannot turn a + // missing row into a 500. 401 rather than 404 -- from the caller's point of + // view the session is unusable, and it keeps /me from being a user-existence + // oracle. + if (!me) { + return json(401, { message: 'Authentication required' }); + } + + return json(200, { + userId: me.user_id, + cognitoSub: me.cognito_sub, + email: me.email, + name: me.name, + isAdmin: me.is_admin === true, + profileImage: me.profile_image, + }); +} + +/** POST /logout -- revokes every token issued to the caller's Cognito session. */ +export async function handleLogout(event: any): Promise { + const authHeader = event.headers?.authorization || event.headers?.Authorization; + if (!authHeader) { + return json(401, { message: 'Authorization header is required' }); + } + + // Extract token (remove "Bearer " prefix if present) + const accessToken = authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : authHeader; + + if (!accessToken) { + return json(401, { message: 'Access token is required' }); + } + + const params: GlobalSignOutCommandInput = { + AccessToken: accessToken, + }; + + try { + await cognitoClient.send(new GlobalSignOutCommand(params)); + return json(200, { message: 'Logged out successfully' }); + } catch (error: any) { + console.error('Logout error:', error); + + if (error.name === 'NotAuthorizedException') { + return json(401, { message: 'Invalid or expired token' }); + } + + return json(500, { message: 'Failed to logout' }); + } +} diff --git a/apps/backend/lambdas/auth/controllers/mfa.ts b/apps/backend/lambdas/auth/controllers/mfa.ts new file mode 100644 index 00000000..65d09ea9 --- /dev/null +++ b/apps/backend/lambdas/auth/controllers/mfa.ts @@ -0,0 +1,187 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + AssociateSoftwareTokenCommand, + VerifySoftwareTokenCommand, + SetUserMFAPreferenceCommand, + GetUserCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json, parseBody } from '@branch/lambda-http'; +import type { RouteHandler } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; +import { cognitoClient } from '../services/cognito'; + +/** + * Bearer token for the four MFA endpoints below. These call + * AssociateSoftwareToken / VerifySoftwareToken / SetUserMFAPreference / GetUser, + * which Cognito authorizes against the access token itself. + */ +function getBearerAccessToken(event: any): string | null { + const authHeader = event.headers?.authorization || event.headers?.Authorization; + if (!authHeader) return null; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader; + return token || null; +} + +/** Cognito error -> HTTP mapping shared by the four MFA endpoints. */ +function mapMfaError(error: any): APIGatewayProxyResult { + console.error('Cognito MFA error:', error); + const code = error?.name; + + switch (code) { + case 'NotAuthorizedException': + return json(401, { message: 'Access token is invalid or expired', code }); + case 'CodeMismatchException': + return json(400, { message: 'Invalid verification code', code }); + case 'EnableSoftwareTokenMFAException': + return json(400, { message: 'Could not enable MFA with that code', code }); + case 'SoftwareTokenMFANotFoundException': + return json(400, { + message: 'No MFA enrollment in progress, call /mfa-setup again', + code, + }); + case 'TooManyRequestsException': + case 'LimitExceededException': + return json(429, { message: 'Too many attempts, please try again later', code }); + default: + return json(500, { message: 'MFA request failed', error: error?.message, code }); + } +} + +/** + * POST /auth/mfa-setup + * + * Starts TOTP enrollment for the signed-in user. AssociateSoftwareToken hands + * back a fresh secret every call -- the caller is expected to follow up with + * POST /auth/mfa-verify using the *same* secret's current code, not a stale one + * from an earlier call. + */ +export const handleMfaSetup: RouteHandler = async ({ event }) => { + const accessToken = getBearerAccessToken(event); + if (!accessToken) { + return json(401, { message: 'Authorization header is required' }); + } + + 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(['email']) + .executeTakeFirst(); + + try { + const response = await cognitoClient.send( + new AssociateSoftwareTokenCommand({ AccessToken: accessToken }), + ); + + const secretCode = response.SecretCode; + if (!secretCode) { + return json(500, { message: 'Failed to generate MFA secret' }); + } + + const label = encodeURIComponent(`BRANCH:${me?.email ?? authContext.user.cognitoSub}`); + const otpauthUrl = `otpauth://totp/${label}?secret=${secretCode}&issuer=BRANCH`; + + return json(200, { secretCode, otpauthUrl }); + } catch (error: any) { + return mapMfaError(error); + } +}; + +/** + * POST /auth/mfa-verify + * + * Confirms the code from an authenticator app and, only on success, enables + * SOFTWARE_TOKEN_MFA as the user's preferred factor. VerifySoftwareToken alone + * does not turn MFA on -- SetUserMFAPreference is a separate call. + */ +export const handleMfaVerify: RouteHandler = async ({ event }) => { + const accessToken = getBearerAccessToken(event); + if (!accessToken) { + return json(401, { message: 'Authorization header is required' }); + } + + const body = parseBody(event); + if (!body) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { code } = body; + if (!code) { + return json(400, { message: 'code is required' }); + } + + try { + const verifyResponse = await cognitoClient.send( + new VerifySoftwareTokenCommand({ + AccessToken: accessToken, + UserCode: String(code), + FriendlyDeviceName: 'Authenticator app', + }), + ); + + if (verifyResponse.Status !== 'SUCCESS') { + return json(400, { message: 'Invalid verification code' }); + } + + await cognitoClient.send( + new SetUserMFAPreferenceCommand({ + AccessToken: accessToken, + SoftwareTokenMfaSettings: { Enabled: true, PreferredMfa: true }, + }), + ); + + return json(200, { message: 'MFA enabled' }); + } catch (error: any) { + return mapMfaError(error); + } +}; + +/** + * POST /auth/mfa-disable + * + * Turns SOFTWARE_TOKEN_MFA back off for the signed-in user. Does not revoke the + * underlying TOTP secret in the authenticator app -- re-enrolling via + * /auth/mfa-setup issues a new one, so a disabled-then-re-enabled account never + * silently trusts the old code. + */ +export const handleMfaDisable: RouteHandler = async ({ event }) => { + const accessToken = getBearerAccessToken(event); + if (!accessToken) { + return json(401, { message: 'Authorization header is required' }); + } + + try { + await cognitoClient.send( + new SetUserMFAPreferenceCommand({ + AccessToken: accessToken, + SoftwareTokenMfaSettings: { Enabled: false, PreferredMfa: false }, + }), + ); + return json(200, { message: 'MFA disabled' }); + } catch (error: any) { + return mapMfaError(error); + } +}; + +/** GET /auth/mfa-status -- whether the signed-in user currently has TOTP MFA enabled. */ +export const handleMfaStatus: RouteHandler = async ({ event }) => { + const accessToken = getBearerAccessToken(event); + if (!accessToken) { + return json(401, { message: 'Authorization header is required' }); + } + + try { + const response = await cognitoClient.send( + new GetUserCommand({ AccessToken: accessToken }), + ); + const enabled = (response.UserMFASettingList || []).includes('SOFTWARE_TOKEN_MFA'); + return json(200, { enabled }); + } catch (error: any) { + return mapMfaError(error); + } +}; diff --git a/apps/backend/lambdas/auth/controllers/password.ts b/apps/backend/lambdas/auth/controllers/password.ts new file mode 100644 index 00000000..b4fb4ca7 --- /dev/null +++ b/apps/backend/lambdas/auth/controllers/password.ts @@ -0,0 +1,82 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + ForgotPasswordCommand, + ForgotPasswordCommandInput, + ConfirmForgotPasswordCommand, + ConfirmForgotPasswordCommandInput, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json } from '@branch/lambda-http'; +import { cognitoClient, USER_POOL_CLIENT_ID } from '../services/cognito'; + +export async function handleForgotPassword(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email } = body; + if (!email) { + return json(400, { message: 'email is required' }); + } + + const params: ForgotPasswordCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: (email as string).toLowerCase(), + }; + + try { + const response = await cognitoClient.send(new ForgotPasswordCommand(params)); + return json(200, { + message: 'Password reset code sent', + deliveryMedium: response.CodeDeliveryDetails?.DeliveryMedium, + destination: response.CodeDeliveryDetails?.Destination, + }); + } catch (error: any) { + console.error('Forgot password error:', error); + if (error.name === 'UserNotFoundException') { + // Don't reveal whether the user exists + return json(200, { message: 'If an account with that email exists, a reset code has been sent' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many requests, please try again later' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: 'Cannot reset password for unverified email. Please verify your email first.' }); + } + return json(500, { message: 'Failed to initiate password reset' }); + } +} + +export async function handleResetPassword(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email, code, newPassword } = body; + if (!email || !code || !newPassword) { + return json(400, { message: 'email, code, and newPassword are required' }); + } + + const params: ConfirmForgotPasswordCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: (email as string).toLowerCase(), + ConfirmationCode: code as string, + Password: newPassword as string, + }; + + try { + await cognitoClient.send(new ConfirmForgotPasswordCommand(params)); + return json(200, { message: 'Password reset successfully' }); + } catch (error: any) { + console.error('Reset password error:', error); + if (error.name === 'CodeMismatchException') { + return json(400, { message: 'Invalid verification code' }); + } + if (error.name === 'ExpiredCodeException') { + return json(400, { message: 'Verification code has expired, please request a new one' }); + } + if (error.name === 'InvalidPasswordException') { + return json(400, { message: 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)' }); + } + if (error.name === 'UserNotFoundException') { + return json(400, { message: 'Invalid email or code' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + return json(500, { message: 'Failed to reset password' }); + } +} diff --git a/apps/backend/lambdas/auth/controllers/register.ts b/apps/backend/lambdas/auth/controllers/register.ts new file mode 100644 index 00000000..c650ca95 --- /dev/null +++ b/apps/backend/lambdas/auth/controllers/register.ts @@ -0,0 +1,284 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + SignUpCommand, + SignUpCommandInput, + AdminDeleteUserCommand, + AdminGetUserCommand, + ConfirmSignUpCommand, + ConfirmSignUpCommandInput, + ResendConfirmationCodeCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json } from '@branch/lambda-http'; +import db from '../db'; +import { cognitoClient, USER_POOL_CLIENT_ID, USER_POOL_ID, validatePassword } from '../services/cognito'; + +export async function handleRegister(event: any): Promise { + try { + // Parse request body + const body = event.body ? JSON.parse(event.body) : {}; + const { email, password, name } = body; + + // Validate required fields + if (!email || !password || !name) { + return json(400, { + message: 'Missing required fields', + required: ['email', 'password', 'name'], + }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return json(400, { message: 'Invalid email format' }); + } + + // Validate password requirements + const passwordError = validatePassword(password); + if (passwordError) { + return json(400, { message: passwordError }); + } + + // Validate name + if (name.trim().length < 2) { + return json(400, { message: 'Name must be at least 2 characters long' }); + } + + // A branch.users row with cognito_sub IS NULL is a PENDING INVITATION, not a + // conflict. Two paths create them: the db/seed.sql rows and admin + // POST /users. Before claim-on-register both were permanently unable to sign + // in -- registration 409'd on the email, and lambda-auth's authenticateRequest + // can never match a NULL cognito_sub. + const existingUser = await db + .selectFrom('branch.users') + .where('email', '=', email.toLowerCase()) + .selectAll() + .executeTakeFirst(); + + if (existingUser && existingUser.cognito_sub) { + return json(409, { message: 'User with this email already exists' }); + } + + // REGISTRATION IS INVITATION-ONLY. This endpoint is public and + // unauthenticated, so without this gate anyone could create a working + // account for themselves. An account is only meaningful once a branch.users + // row exists -- authenticateRequest rejects any Cognito identity whose sub + // has no row -- so refusing to create that row here is the control. + // + // The invitation must be created first by an admin via the ADMIN-gated + // POST /users, which inserts a row with a NULL cognito_sub. + // + // 403 rather than 404: this endpoint must not become an oracle for which + // email addresses have been invited, so the response is deliberately the + // same whether or not the address is known. + if (!existingUser) { + return json(403, { + message: + 'Registration is by invitation only. Ask an administrator to create your account.', + code: 'INVITATION_REQUIRED', + }); + } + + const claimingUserId: number = existingUser.user_id; + + // Prepare Cognito SignUp parameters + const signUpParams: SignUpCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: email.toLowerCase(), + Password: password, + UserAttributes: [ + { + Name: 'email', + Value: email.toLowerCase(), + }, + { + Name: 'name', + Value: name.trim(), + }, + ], + }; + + // Register user in Cognito + let cognitoUserSub: string; + try { + const command = new SignUpCommand(signUpParams); + const response = await cognitoClient.send(command); + cognitoUserSub = response.UserSub!; + } catch (error: any) { + console.error('Cognito registration error:', error); + + // Handle specific Cognito errors + if (error.name === 'UsernameExistsException') { + // The Cognito user exists but this DB row is an unclaimed invitation, so + // SignUp can never hand us a sub. Happens routinely in local dev: `make + // down-v` wipes Postgres while the shared dev pool keeps the user. Link + // the existing Cognito identity instead of dead-ending on a 409. + { + try { + // AdminGetUser is SigV4-signed and needs cognito-idp:AdminGetUser + // (granted in infrastructure/aws/lambda.tf). With no AWS credentials + // locally this throws and we fall through to the 409. + const cognitoUser = await cognitoClient.send( + new AdminGetUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email.toLowerCase(), + }), + ); + const sub = cognitoUser.UserAttributes?.find((a) => a.Name === 'sub')?.Value; + if (sub && cognitoUser.UserStatus === 'CONFIRMED') { + const linkResult = await db + .updateTable('branch.users') + .set({ cognito_sub: sub }) + .where('user_id', '=', claimingUserId) + .where('cognito_sub', 'is', null) + .executeTakeFirst(); + // A concurrent claim already took this row; do not delete the + // pre-existing Cognito user, it may back a working account. + if (linkResult.numUpdatedRows > 0n) { + return json(200, { + message: 'Existing account linked', + claimed: true, + email: email.toLowerCase(), + }); + } + } + } catch (linkError) { + console.warn('Could not auto-link existing Cognito user:', linkError); + } + } + return json(409, { + message: 'User with this email already exists', + code: 'COGNITO_USER_EXISTS', + }); + } + if (error.name === 'InvalidPasswordException') { + return json(400, { message: 'Password does not meet requirements' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: error.message || 'Invalid parameters provided' }); + } + + return json(500, { message: 'Failed to register user in authentication service' }); + } + + const rollbackCognitoUser = async () => { + try { + await cognitoClient.send( + new AdminDeleteUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email.toLowerCase(), + }) + ); + console.log('Rolled back Cognito user after database failure'); + } catch (rollbackError) { + console.error('Failed to rollback Cognito user:', rollbackError); + } + }; + + // Create user in database, or claim the pending invitation + try { + // Claim the invitation. is_admin is deliberately NOT touched: it was set + // by whoever created the invitation (a seed, or an admin via POST /users) + // and must never be settable from a public, unauthenticated endpoint. + // There is no insert path here -- registration cannot mint a new row, only + // claim one an admin already approved. The cognito_sub IS NULL predicate + // makes a concurrent claim a no-op rather than an overwrite; + // UNIQUE(cognito_sub) is the backstop. + const claimResult = await db + .updateTable('branch.users') + .set({ cognito_sub: cognitoUserSub, name: name.trim() }) + .where('user_id', '=', claimingUserId) + .where('cognito_sub', 'is', null) + .executeTakeFirst(); + + // No-op claim: the Cognito sub we just created would reference no row, so + // every later login would fail. Undo the Cognito user instead. + if (claimResult.numUpdatedRows === 0n) { + console.error('Invitation already claimed for user_id:', claimingUserId); + await rollbackCognitoUser(); + return json(409, { + message: 'User with this email already exists', + code: 'ALREADY_CLAIMED', + }); + } + } catch (dbError: any) { + console.error('Database insert error:', dbError); + + // Rollback: Delete user from Cognito if database insert fails + await rollbackCognitoUser(); + + return json(500, { message: 'Failed to create user account' }); + } + + return json(201, { + message: 'User registered successfully', + userId: cognitoUserSub, + email: email.toLowerCase(), + name: name.trim(), + emailVerificationRequired: true, + details: 'Please check your email for verification code', + claimed: true, + }); + } catch (error: any) { + console.error('Registration error:', error); + return json(500, { message: 'Internal server error during registration' }); + } +} + +export async function handleVerifyEmail(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email, code } = body; + if (!email || !code) { + return json(400, { message: 'email and code are required' }); + } + const params: ConfirmSignUpCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + ConfirmationCode: code as string, + }; + try { + await cognitoClient.send(new ConfirmSignUpCommand(params)); + } catch (error: any) { + console.error('Email verification error:', error); + if (error.name === 'NotAuthorizedException' && error.message?.includes('CONFIRMED')) { + return json(200, { message: `Email already verified for ${email}` }); + } + if (error.name === 'CodeMismatchException' || error.name === 'ExpiredCodeException') { + return json(400, { message: 'Invalid or expired verification code' }); + } + if (error.name === 'UserNotFoundException') { + return json(400, { message: 'Invalid code or email' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + return json(500, { message: 'Failed to verify email' }); + } + return json(200, { message: `Email verified successfully for ${email}` }); +} + +export async function handleResendCode(event: any): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email } = body; + if (!email) { + return json(400, { message: 'email is required' }); + } + try { + await cognitoClient.send(new ResendConfirmationCodeCommand({ + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + })); + return json(200, { message: `Verification code resent to ${email}` }); + } catch (error: any) { + if (error.name === 'UserNotFoundException') { + return json(404, { message: 'User not found' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: 'User is already confirmed' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + console.error('Resend code error:', error); + return json(500, { message: 'Failed to resend verification code' }); + } +} diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 06c13c62..965afca6 100644 --- a/apps/backend/lambdas/auth/handler.ts +++ b/apps/backend/lambdas/auth/handler.ts @@ -1,1046 +1,4 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { - CognitoIdentityProviderClient, - SignUpCommand, - SignUpCommandInput, - AdminDeleteUserCommand, - AdminGetUserCommand, - InitiateAuthCommand, - InitiateAuthCommandInput, - InitiateAuthCommandOutput, - RespondToAuthChallengeCommand, - RespondToAuthChallengeCommandOutput, - ConfirmSignUpCommand, - ConfirmSignUpCommandInput, - ResendConfirmationCodeCommand, +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; - GlobalSignOutCommand, - GlobalSignOutCommandInput, - ForgotPasswordCommand, - ForgotPasswordCommandInput, - ConfirmForgotPasswordCommand, - ConfirmForgotPasswordCommandInput, - AuthenticationResultType, - ChallengeNameType, - AssociateSoftwareTokenCommand, - VerifySoftwareTokenCommand, - SetUserMFAPreferenceCommand, - GetUserCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import { authenticateRequest } from './auth'; -import db from './db'; - -// Initialize Cognito client (region defaults to us-east-2) -const cognitoClient = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION || 'us-east-2', -}); - -const USER_POOL_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; -const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; - -/** - * How to answer each Cognito auth challenge. - * - * Adding support for a new challenge type is adding a row here -- no routing, - * dispatch or flow changes. That is what makes enabling MFA on the user pool a - * configuration change rather than a code change: SOFTWARE_TOKEN_MFA, SMS_MFA, - * EMAIL_OTP and SELECT_MFA_TYPE are already wired and become reachable the - * moment mfa_configuration is turned on in infrastructure/aws/cognito.tf. - */ -interface ChallengeSpec { - /** Body fields that must be present for this challenge. */ - required: string[]; - /** Builds the Cognito ChallengeResponses map. */ - build: (body: Record, username: string) => Record; -} - -const CHALLENGE_SPECS: Record = { - NEW_PASSWORD_REQUIRED: { - required: ['newPassword'], - build: (body, username) => ({ - USERNAME: username, - NEW_PASSWORD: String(body.newPassword), - ...(body.name ? { 'userAttributes.name': String(body.name) } : {}), - }), - }, - SOFTWARE_TOKEN_MFA: { - required: ['code'], - build: (body, username) => ({ - USERNAME: username, - SOFTWARE_TOKEN_MFA_CODE: String(body.code), - }), - }, - SMS_MFA: { - required: ['code'], - build: (body, username) => ({ - USERNAME: username, - SMS_MFA_CODE: String(body.code), - }), - }, - EMAIL_OTP: { - required: ['code'], - build: (body, username) => ({ - USERNAME: username, - EMAIL_OTP_CODE: String(body.code), - }), - }, - SELECT_MFA_TYPE: { - required: ['mfaType'], - build: (body, username) => ({ - USERNAME: username, - ANSWER: String(body.mfaType), - }), - }, -}; - -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /auth[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/auth(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // POST /register - if (normalizedPath === '/register' && method === 'POST') { - return await handleRegister(event); - } - - - // POST /login - if (normalizedPath === '/login' && method === 'POST') { - return await handleLogin(event); - } - - // POST /respond-challenge - if (normalizedPath === '/respond-challenge' && method === 'POST') { - return await handleRespondChallenge(event); - } - - // POST /refresh - if (normalizedPath === '/refresh' && method === 'POST') { - return await handleRefresh(event); - } - - // GET /me - if (normalizedPath === '/me' && method === 'GET') { - return await handleMe(event); - } - - // POST /verify-email - if (normalizedPath === '/verify-email' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email, code } = body; - if (!email || !code) { - return json(400, { message: 'email and code are required' }); - } - const params: ConfirmSignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - ConfirmationCode: code as string, - }; - try { - await cognitoClient.send(new ConfirmSignUpCommand(params)); - } catch (error: any) { - console.error('Email verification error:', error); - if (error.name === 'NotAuthorizedException' && error.message?.includes('CONFIRMED')) { - return json(200, { message: `Email already verified for ${email}` }); - } - if (error.name === 'CodeMismatchException' || error.name === 'ExpiredCodeException') { - return json(400, { message: 'Invalid or expired verification code' }); - } - if (error.name === 'UserNotFoundException') { - return json(400, { message: 'Invalid code or email' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - return json(500, { message: 'Failed to verify email' }); - } - return json(200, { message: `Email verified successfully for ${email}` }); - } - - // POST /resend-code - if (normalizedPath === '/resend-code' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email } = body; - if (!email) { - return json(400, { message: 'email is required' }); - } - try { - await cognitoClient.send(new ResendConfirmationCodeCommand({ - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - })); - return json(200, { message: `Verification code resent to ${email}` }); - } catch (error: any) { - if (error.name === 'UserNotFoundException') { - return json(404, { message: 'User not found' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: 'User is already confirmed' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - console.error('Resend code error:', error); - return json(500, { message: 'Failed to resend verification code' }); - } - } - - // POST /logout - if (normalizedPath === '/logout' && method === 'POST') { - const authHeader = event.headers?.authorization || event.headers?.Authorization; - if (!authHeader) { - return json(401, { message: 'Authorization header is required' }); - } - - // Extract token (remove "Bearer " prefix if present) - const accessToken = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) - : authHeader; - - if (!accessToken) { - return json(401, { message: 'Access token is required' }); - } - - const params: GlobalSignOutCommandInput = { - AccessToken: accessToken, - }; - - try { - await cognitoClient.send(new GlobalSignOutCommand(params)); - return json(200, { message: 'Logged out successfully' }); - } catch (error: any) { - console.error('Logout error:', error); - - if (error.name === 'NotAuthorizedException') { - return json(401, { message: 'Invalid or expired token' }); - } - - return json(500, { message: 'Failed to logout' }); - } - } - - // POST /forgot-password - if (normalizedPath === '/forgot-password' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email } = body; - if (!email) { - return json(400, { message: 'email is required' }); - } - - const params: ForgotPasswordCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: (email as string).toLowerCase(), - }; - - try { - const response = await cognitoClient.send(new ForgotPasswordCommand(params)); - return json(200, { - message: 'Password reset code sent', - deliveryMedium: response.CodeDeliveryDetails?.DeliveryMedium, - destination: response.CodeDeliveryDetails?.Destination, - }); - } catch (error: any) { - console.error('Forgot password error:', error); - if (error.name === 'UserNotFoundException') { - // Don't reveal whether the user exists - return json(200, { message: 'If an account with that email exists, a reset code has been sent' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many requests, please try again later' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: 'Cannot reset password for unverified email. Please verify your email first.' }); - } - return json(500, { message: 'Failed to initiate password reset' }); - } - } - - // POST /reset-password - if (normalizedPath === '/reset-password' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email, code, newPassword } = body; - if (!email || !code || !newPassword) { - return json(400, { message: 'email, code, and newPassword are required' }); - } - - const params: ConfirmForgotPasswordCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: (email as string).toLowerCase(), - ConfirmationCode: code as string, - Password: newPassword as string, - }; - - try { - await cognitoClient.send(new ConfirmForgotPasswordCommand(params)); - return json(200, { message: 'Password reset successfully' }); - } catch (error: any) { - console.error('Reset password error:', error); - if (error.name === 'CodeMismatchException') { - return json(400, { message: 'Invalid verification code' }); - } - if (error.name === 'ExpiredCodeException') { - return json(400, { message: 'Verification code has expired, please request a new one' }); - } - if (error.name === 'InvalidPasswordException') { - return json(400, { message: 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)' }); - } - if (error.name === 'UserNotFoundException') { - return json(400, { message: 'Invalid email or code' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - return json(500, { message: 'Failed to reset password' }); - } - } - - // POST /mfa-setup - if (normalizedPath === '/mfa-setup' && method === 'POST') { - return await handleMfaSetup(event); - } - - // POST /mfa-verify - if (normalizedPath === '/mfa-verify' && method === 'POST') { - return await handleMfaVerify(event); - } - - // POST /mfa-disable - if (normalizedPath === '/mfa-disable' && method === 'POST') { - return await handleMfaDisable(event); - } - - // GET /mfa-status - if (normalizedPath === '/mfa-status' && method === 'GET') { - return await handleMfaStatus(event); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -/** Parses a JSON body, returning null when it is not valid JSON. */ -function parseBody(event: any): Record | null { - try { - return event.body ? (JSON.parse(event.body) as Record) : {}; - } catch { - return null; - } -} - -/** - * Password rules, kept in one place so /register and /respond-challenge cannot - * drift. Returns an error message, or null when the password is acceptable. - * Mirrors the pool's password_policy in infrastructure/aws/cognito.tf. - */ -function validatePassword(password: unknown): string | null { - if (typeof password !== 'string') return 'Password must be a string'; - if (password.length < 8) return 'Password must be at least 8 characters long'; - if (!/[a-z]/.test(password)) return 'Password must contain at least one lowercase letter'; - if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter'; - if (!/[0-9]/.test(password)) return 'Password must contain at least one number'; - return null; -} - -/** 200 + the token set. Shape matches what the frontend AuthContext expects. */ -function authResultResponse(result: AuthenticationResultType): APIGatewayProxyResult { - return json(200, { - AccessToken: result.AccessToken, - IdToken: result.IdToken, - // Absent on REFRESH_TOKEN_AUTH: Cognito does not re-issue a refresh token. - RefreshToken: result.RefreshToken, - ExpiresIn: result.ExpiresIn, - TokenType: result.TokenType, - }); -} - -/** - * 200 + the challenge to answer next. The opaque Session is valid across - * processes, so the client can complete it with a separate request to - * POST /auth/respond-challenge. - */ -function challengeResponse( - response: InitiateAuthCommandOutput | RespondToAuthChallengeCommandOutput, -): APIGatewayProxyResult { - return json(200, { - ChallengeName: response.ChallengeName, - Session: response.Session, - ChallengeParameters: response.ChallengeParameters, - message: `Additional authentication step required: ${response.ChallengeName}`, - }); -} - -/** Single Cognito error -> HTTP mapping, shared by login, challenge and refresh. */ -function mapCognitoAuthError( - error: any, - stage: 'login' | 'challenge' | 'refresh', -): APIGatewayProxyResult { - console.error(`Cognito ${stage} error:`, error); - const code = error?.name; - - switch (code) { - case 'NotAuthorizedException': { - const message = - stage === 'refresh' - ? 'Refresh token is invalid or expired' - : stage === 'challenge' - ? 'Challenge session is invalid or expired, please sign in again' - : 'Invalid email or password'; - return json(401, { message, code }); - } - // prevent_user_existence_errors is ENABLED on the app client, so Cognito - // normally folds this into NotAuthorizedException. Handled for parity. - case 'UserNotFoundException': - return json(401, { message: 'Invalid email or password', code }); - case 'UserNotConfirmedException': - return json(403, { message: 'Email not verified', code }); - case 'PasswordResetRequiredException': - return json(403, { message: 'Password reset required', code }); - case 'CodeMismatchException': - return json(400, { message: 'Invalid verification code', code }); - case 'ExpiredCodeException': - return json(400, { message: 'Verification code has expired', code }); - case 'InvalidPasswordException': - return json(400, { - message: - 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)', - code, - }); - case 'InvalidParameterException': - return json(400, { message: error?.message || 'Invalid parameters provided', code }); - case 'TooManyRequestsException': - case 'LimitExceededException': - case 'TooManyFailedAttemptsException': - return json(429, { message: 'Too many attempts, please try again later', code }); - case 'ForbiddenException': - return json(403, { message: 'Request blocked', code }); - default: - return json(500, { message: 'Authentication failed', error: error?.message, code }); - } -} - -/** - * POST /login - * - * Uses USER_PASSWORD_AUTH rather than SRP. The browser already posts the - * plaintext password to this endpoint over TLS, so server-side SRP adds no - * confidentiality -- and unlike the SRP library, the SDK hands back the - * challenge Session as an opaque string that survives across invocations, - * which is what makes a stateless POST /respond-challenge possible. - * - * Every branch returns. An unrecognised ChallengeName is passed to the client - * as a value rather than silently never resolving a promise, which is how the - * previous callback-based implementation hung until the 30s lambda timeout. - */ -async function handleLogin(event: any): Promise { - const body = parseBody(event); - if (!body) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { email, password } = body; - if (!email || !password) { - return json(400, { message: 'email and password are required' }); - } - - // Registration stores email.toLowerCase(), so sign-in must match. - const username = String(email).toLowerCase(); - - const params: InitiateAuthCommandInput = { - AuthFlow: 'USER_PASSWORD_AUTH', - ClientId: USER_POOL_CLIENT_ID, - // No SECRET_HASH: the app client is created with generate_secret = false. - AuthParameters: { USERNAME: username, PASSWORD: String(password) }, - }; - - try { - const response = await cognitoClient.send(new InitiateAuthCommand(params)); - - if (response.AuthenticationResult) { - return authResultResponse(response.AuthenticationResult); - } - - if (response.ChallengeName) { - // MFA_SETUP cannot be answered by RespondToAuthChallenge alone -- it needs - // AssociateSoftwareToken/VerifySoftwareToken enrollment, which is not - // built yet. Return the Session anyway so a future enrollment endpoint can - // resume without forcing a fresh sign-in. - if (response.ChallengeName === 'MFA_SETUP') { - return json(403, { - ChallengeName: response.ChallengeName, - Session: response.Session, - message: 'MFA enrollment is required but not yet supported', - }); - } - return challengeResponse(response); - } - - return json(500, { message: 'Unexpected response from authentication service' }); - } catch (error: any) { - return mapCognitoAuthError(error, 'login'); - } -} - -/** - * POST /respond-challenge - * - * Answers whatever POST /login returned, using the opaque Session string. - * Responses chain: a challenge may be followed by another challenge (the usual - * NEW_PASSWORD_REQUIRED then TOTP-enrollment path), so the caller must branch on - * the response the same way it branches on /login. - */ -async function handleRespondChallenge(event: any): Promise { - const body = parseBody(event); - if (!body) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { challengeName, session, email } = body; - if (!challengeName || !session || !email) { - return json(400, { - message: 'challengeName, session, and email are required', - }); - } - - const spec = CHALLENGE_SPECS[String(challengeName)]; - if (!spec) { - return json(400, { - message: `Unsupported challenge: ${challengeName}`, - supported: Object.keys(CHALLENGE_SPECS), - }); - } - - for (const field of spec.required) { - if (!body[field]) { - return json(400, { message: `${field} is required for ${challengeName}` }); - } - } - - if (challengeName === 'NEW_PASSWORD_REQUIRED') { - const passwordError = validatePassword(body.newPassword); - if (passwordError) { - return json(400, { message: passwordError }); - } - } - - try { - const response = await cognitoClient.send( - new RespondToAuthChallengeCommand({ - ClientId: USER_POOL_CLIENT_ID, - ChallengeName: challengeName as ChallengeNameType, - Session: String(session), - ChallengeResponses: spec.build(body, String(email).toLowerCase()), - }), - ); - - if (response.AuthenticationResult) { - return authResultResponse(response.AuthenticationResult); - } - if (response.ChallengeName) { - return challengeResponse(response); - } - return json(500, { message: 'Unexpected response from authentication service' }); - } catch (error: any) { - return mapCognitoAuthError(error, 'challenge'); - } -} - -/** - * POST /refresh - * - * Exchanges a refresh token for a new access and ID token. Cognito does NOT - * return a new refresh token here (no rotation is configured), so the client - * must keep the one it already stored until it expires. - */ -async function handleRefresh(event: any): Promise { - const body = parseBody(event); - if (!body) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { refreshToken } = body; - if (!refreshToken) { - return json(400, { message: 'refreshToken is required' }); - } - - try { - const response = await cognitoClient.send( - new InitiateAuthCommand({ - AuthFlow: 'REFRESH_TOKEN_AUTH', - ClientId: USER_POOL_CLIENT_ID, - AuthParameters: { REFRESH_TOKEN: String(refreshToken) }, - }), - ); - - if (!response.AuthenticationResult) { - return json(401, { message: 'Refresh token is invalid or expired' }); - } - return authResultResponse(response.AuthenticationResult); - } catch (error: any) { - return mapCognitoAuthError(error, 'refresh'); - } -} - -/** - * GET /me -- the canonical session bootstrap endpoint. - * - * Everything is read from Postgres rather than the token, for two reasons: a - * Cognito *access* token carries sub/scope/client_id/token_use but neither email - * nor name, and is_admin exists only in branch.users -- there is no - * pre-token-generation trigger, so it is not a JWT claim. This endpoint is the - * only way the frontend can learn whether the caller is an admin. - */ -async function handleMe(event: any): Promise { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const me = await db - .selectFrom('branch.users') - .where('cognito_sub', '=', authContext.user.cognitoSub) - .select(['user_id', 'cognito_sub', 'email', 'name', 'is_admin', 'profile_image']) - .executeTakeFirst(); - - // Defensive: authenticateRequest already rejects a token whose sub has no row, - // so this is unreachable today. Kept so a future refactor cannot turn a - // missing row into a 500. 401 rather than 404 -- from the caller's point of - // view the session is unusable, and it keeps /me from being a user-existence - // oracle. - if (!me) { - return json(401, { message: 'Authentication required' }); - } - - return json(200, { - userId: me.user_id, - cognitoSub: me.cognito_sub, - email: me.email, - name: me.name, - isAdmin: me.is_admin === true, - profileImage: me.profile_image, - }); -} - -/** - * Bearer token for the four MFA endpoints below. These call - * AssociateSoftwareToken / VerifySoftwareToken / SetUserMFAPreference / GetUser - * with the caller's own AccessToken -- user-context Cognito calls, not Admin* - * ones, so no IAM change was needed to grant them (see infrastructure/AGENTS.md). - */ -function getBearerAccessToken(event: any): string | null { - const authHeader = event.headers?.authorization || event.headers?.Authorization; - if (!authHeader) return null; - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader; - return token || null; -} - -/** Cognito error -> HTTP mapping shared by the four MFA endpoints. */ -function mapMfaError(error: any): APIGatewayProxyResult { - console.error('Cognito MFA error:', error); - const code = error?.name; - - switch (code) { - case 'NotAuthorizedException': - return json(401, { message: 'Access token is invalid or expired', code }); - case 'CodeMismatchException': - return json(400, { message: 'Invalid verification code', code }); - case 'EnableSoftwareTokenMFAException': - return json(400, { message: 'Could not enable MFA with that code', code }); - case 'SoftwareTokenMFANotFoundException': - return json(400, { - message: 'No MFA enrollment in progress, call /mfa-setup again', - code, - }); - case 'TooManyRequestsException': - case 'LimitExceededException': - return json(429, { message: 'Too many attempts, please try again later', code }); - default: - return json(500, { message: 'MFA request failed', error: error?.message, code }); - } -} - -/** - * POST /mfa-setup - * - * Starts TOTP enrollment for the signed-in user. AssociateSoftwareToken hands - * back a fresh secret every call -- the caller is expected to follow up with - * POST /mfa-verify using the *same* secret's current code, not a stale one from - * an earlier call. - */ -async function handleMfaSetup(event: any): Promise { - const accessToken = getBearerAccessToken(event); - if (!accessToken) { - return json(401, { message: 'Authorization header is required' }); - } - - 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(['email']) - .executeTakeFirst(); - - try { - const response = await cognitoClient.send( - new AssociateSoftwareTokenCommand({ AccessToken: accessToken }), - ); - - const secretCode = response.SecretCode; - if (!secretCode) { - return json(500, { message: 'Failed to generate MFA secret' }); - } - - const label = encodeURIComponent(`BRANCH:${me?.email ?? authContext.user.cognitoSub}`); - const otpauthUrl = `otpauth://totp/${label}?secret=${secretCode}&issuer=BRANCH`; - - return json(200, { secretCode, otpauthUrl }); - } catch (error: any) { - return mapMfaError(error); - } -} - -/** - * POST /mfa-verify - * - * Confirms the code from an authenticator app and, only on success, enables - * SOFTWARE_TOKEN_MFA as the user's preferred factor. VerifySoftwareToken alone - * does not turn MFA on -- SetUserMFAPreference is a separate call. - */ -async function handleMfaVerify(event: any): Promise { - const accessToken = getBearerAccessToken(event); - if (!accessToken) { - return json(401, { message: 'Authorization header is required' }); - } - - const body = parseBody(event); - if (!body) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { code } = body; - if (!code) { - return json(400, { message: 'code is required' }); - } - - try { - const verifyResponse = await cognitoClient.send( - new VerifySoftwareTokenCommand({ - AccessToken: accessToken, - UserCode: String(code), - FriendlyDeviceName: 'Authenticator app', - }), - ); - - if (verifyResponse.Status !== 'SUCCESS') { - return json(400, { message: 'Invalid verification code' }); - } - - await cognitoClient.send( - new SetUserMFAPreferenceCommand({ - AccessToken: accessToken, - SoftwareTokenMfaSettings: { Enabled: true, PreferredMfa: true }, - }), - ); - - return json(200, { message: 'MFA enabled' }); - } catch (error: any) { - return mapMfaError(error); - } -} - -/** - * POST /mfa-disable - * - * Turns SOFTWARE_TOKEN_MFA back off for the signed-in user. Does not revoke the - * underlying TOTP secret in the authenticator app -- re-enrolling via - * /mfa-setup issues a new one, so a disabled-then-re-enabled account never - * silently trusts the old code. - */ -async function handleMfaDisable(event: any): Promise { - const accessToken = getBearerAccessToken(event); - if (!accessToken) { - return json(401, { message: 'Authorization header is required' }); - } - - try { - await cognitoClient.send( - new SetUserMFAPreferenceCommand({ - AccessToken: accessToken, - SoftwareTokenMfaSettings: { Enabled: false, PreferredMfa: false }, - }), - ); - return json(200, { message: 'MFA disabled' }); - } catch (error: any) { - return mapMfaError(error); - } -} - -/** GET /mfa-status -- whether the signed-in user currently has TOTP MFA enabled. */ -async function handleMfaStatus(event: any): Promise { - const accessToken = getBearerAccessToken(event); - if (!accessToken) { - return json(401, { message: 'Authorization header is required' }); - } - - try { - const response = await cognitoClient.send( - new GetUserCommand({ AccessToken: accessToken }), - ); - const enabled = (response.UserMFASettingList || []).includes('SOFTWARE_TOKEN_MFA'); - return json(200, { enabled }); - } catch (error: any) { - return mapMfaError(error); - } -} - -async function handleRegister(event: any): Promise { - try { - // Parse request body - const body = event.body ? JSON.parse(event.body) : {}; - const { email, password, name } = body; - - // Validate required fields - if (!email || !password || !name) { - return json(400, { - message: 'Missing required fields', - required: ['email', 'password', 'name'], - }); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return json(400, { message: 'Invalid email format' }); - } - - // Validate password requirements - const passwordError = validatePassword(password); - if (passwordError) { - return json(400, { message: passwordError }); - } - - // Validate name - if (name.trim().length < 2) { - return json(400, { message: 'Name must be at least 2 characters long' }); - } - - // A branch.users row with cognito_sub IS NULL is a PENDING INVITATION, not a - // conflict. Two paths create them: the db/seed.sql rows and admin - // POST /users. Before claim-on-register both were permanently unable to sign - // in -- registration 409'd on the email, and lambda-auth's authenticateRequest - // can never match a NULL cognito_sub. - const existingUser = await db - .selectFrom('branch.users') - .where('email', '=', email.toLowerCase()) - .selectAll() - .executeTakeFirst(); - - if (existingUser && existingUser.cognito_sub) { - return json(409, { message: 'User with this email already exists' }); - } - - // REGISTRATION IS INVITATION-ONLY. This endpoint is public and - // unauthenticated, so without this gate anyone could create a working - // account for themselves. An account is only meaningful once a branch.users - // row exists -- authenticateRequest rejects any Cognito identity whose sub - // has no row -- so refusing to create that row here is the control. - // - // The invitation must be created first by an admin via the ADMIN-gated - // POST /users, which inserts a row with a NULL cognito_sub. - // - // 403 rather than 404: this endpoint must not become an oracle for which - // email addresses have been invited, so the response is deliberately the - // same whether or not the address is known. - if (!existingUser) { - return json(403, { - message: - 'Registration is by invitation only. Ask an administrator to create your account.', - code: 'INVITATION_REQUIRED', - }); - } - - const claimingUserId: number = existingUser.user_id; - - // Prepare Cognito SignUp parameters - const signUpParams: SignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email.toLowerCase(), - Password: password, - UserAttributes: [ - { - Name: 'email', - Value: email.toLowerCase(), - }, - { - Name: 'name', - Value: name.trim(), - }, - ], - }; - - // Register user in Cognito - let cognitoUserSub: string; - try { - const command = new SignUpCommand(signUpParams); - const response = await cognitoClient.send(command); - cognitoUserSub = response.UserSub!; - } catch (error: any) { - console.error('Cognito registration error:', error); - - // Handle specific Cognito errors - if (error.name === 'UsernameExistsException') { - // The Cognito user exists but this DB row is an unclaimed invitation, so - // SignUp can never hand us a sub. Happens routinely in local dev: `make - // down-v` wipes Postgres while the shared dev pool keeps the user. Link - // the existing Cognito identity instead of dead-ending on a 409. - { - try { - // AdminGetUser is SigV4-signed and needs cognito-idp:AdminGetUser - // (granted in infrastructure/aws/lambda.tf). With no AWS credentials - // locally this throws and we fall through to the 409. - const cognitoUser = await cognitoClient.send( - new AdminGetUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email.toLowerCase(), - }), - ); - const sub = cognitoUser.UserAttributes?.find((a) => a.Name === 'sub')?.Value; - if (sub && cognitoUser.UserStatus === 'CONFIRMED') { - const linkResult = await db - .updateTable('branch.users') - .set({ cognito_sub: sub }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); - // A concurrent claim already took this row; do not delete the - // pre-existing Cognito user, it may back a working account. - if (linkResult.numUpdatedRows > 0n) { - return json(200, { - message: 'Existing account linked', - claimed: true, - email: email.toLowerCase(), - }); - } - } - } catch (linkError) { - console.warn('Could not auto-link existing Cognito user:', linkError); - } - } - return json(409, { - message: 'User with this email already exists', - code: 'COGNITO_USER_EXISTS', - }); - } - if (error.name === 'InvalidPasswordException') { - return json(400, { message: 'Password does not meet requirements' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: error.message || 'Invalid parameters provided' }); - } - - return json(500, { message: 'Failed to register user in authentication service' }); - } - - const rollbackCognitoUser = async () => { - try { - await cognitoClient.send( - new AdminDeleteUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email.toLowerCase(), - }) - ); - console.log('Rolled back Cognito user after database failure'); - } catch (rollbackError) { - console.error('Failed to rollback Cognito user:', rollbackError); - } - }; - - // Create user in database, or claim the pending invitation - try { - // Claim the invitation. is_admin is deliberately NOT touched: it was set - // by whoever created the invitation (a seed, or an admin via POST /users) - // and must never be settable from a public, unauthenticated endpoint. - // There is no insert path here -- registration cannot mint a new row, only - // claim one an admin already approved. The cognito_sub IS NULL predicate - // makes a concurrent claim a no-op rather than an overwrite; - // UNIQUE(cognito_sub) is the backstop. - const claimResult = await db - .updateTable('branch.users') - .set({ cognito_sub: cognitoUserSub, name: name.trim() }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); - - // No-op claim: the Cognito sub we just created would reference no row, so - // every later login would fail. Undo the Cognito user instead. - if (claimResult.numUpdatedRows === 0n) { - console.error('Invitation already claimed for user_id:', claimingUserId); - await rollbackCognitoUser(); - return json(409, { - message: 'User with this email already exists', - code: 'ALREADY_CLAIMED', - }); - } - } catch (dbError: any) { - console.error('Database insert error:', dbError); - - // Rollback: Delete user from Cognito if database insert fails - await rollbackCognitoUser(); - - return json(500, { message: 'Failed to create user account' }); - } - - return json(201, { - message: 'User registered successfully', - userId: cognitoUserSub, - email: email.toLowerCase(), - name: name.trim(), - emailVerificationRequired: true, - details: 'Please check your email for verification code', - claimed: true, - }); - } catch (error: any) { - console.error('Registration error:', error); - return json(500, { message: 'Internal server error during registration' }); - } -} - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +export const handler = (event: any) => dispatch(event, { prefix: 'auth', routes }); diff --git a/apps/backend/lambdas/auth/package-lock.json b/apps/backend/lambdas/auth/package-lock.json index 469fa6c3..a5253822 100644 --- a/apps/backend/lambdas/auth/package-lock.json +++ b/apps/backend/lambdas/auth/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", @@ -47,6 +48,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1217,6 +1234,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index 276a9c88..8aa1601c 100644 --- a/apps/backend/lambdas/auth/package.json +++ b/apps/backend/lambdas/auth/package.json @@ -27,6 +27,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", diff --git a/apps/backend/lambdas/auth/routes.ts b/apps/backend/lambdas/auth/routes.ts new file mode 100644 index 00000000..31ef5798 --- /dev/null +++ b/apps/backend/lambdas/auth/routes.ts @@ -0,0 +1,37 @@ +import type { Route } from '@branch/lambda-http'; +import { handleRegister, handleVerifyEmail, handleResendCode } from './controllers/register'; +import { + handleLogin, + handleRespondChallenge, + handleRefresh, + handleMe, + handleLogout, +} from './controllers/auth'; +import { handleForgotPassword, handleResetPassword } from './controllers/password'; +import { + handleMfaSetup, + handleMfaVerify, + handleMfaDisable, + handleMfaStatus, +} from './controllers/mfa'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + // CLI-generated routes will be inserted here + + { method: 'POST', pattern: '/auth/register', handler: ({ event }) => handleRegister(event) }, + { method: 'POST', pattern: '/auth/login', handler: ({ event }) => handleLogin(event) }, + { method: 'POST', pattern: '/auth/respond-challenge', handler: ({ event }) => handleRespondChallenge(event) }, + { method: 'POST', pattern: '/auth/refresh', handler: ({ event }) => handleRefresh(event) }, + { method: 'GET', pattern: '/auth/me', handler: ({ event }) => handleMe(event) }, + { method: 'POST', pattern: '/auth/verify-email', handler: ({ event }) => handleVerifyEmail(event) }, + { method: 'POST', pattern: '/auth/resend-code', handler: ({ event }) => handleResendCode(event) }, + { method: 'POST', pattern: '/auth/logout', handler: ({ event }) => handleLogout(event) }, + { method: 'POST', pattern: '/auth/forgot-password', handler: ({ event }) => handleForgotPassword(event) }, + { method: 'POST', pattern: '/auth/reset-password', handler: ({ event }) => handleResetPassword(event) }, + { method: 'POST', pattern: '/auth/mfa-setup', handler: handleMfaSetup }, + { method: 'POST', pattern: '/auth/mfa-verify', handler: handleMfaVerify }, + { method: 'POST', pattern: '/auth/mfa-disable', handler: handleMfaDisable }, + { method: 'GET', pattern: '/auth/mfa-status', handler: handleMfaStatus }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/auth/services/cognito.ts b/apps/backend/lambdas/auth/services/cognito.ts new file mode 100644 index 00000000..220d18db --- /dev/null +++ b/apps/backend/lambdas/auth/services/cognito.ts @@ -0,0 +1,162 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + CognitoIdentityProviderClient, + AuthenticationResultType, + InitiateAuthCommandOutput, + RespondToAuthChallengeCommandOutput, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json } from '@branch/lambda-http'; + +// Initialize Cognito client (region defaults to us-east-2) +export const cognitoClient = new CognitoIdentityProviderClient({ + region: process.env.AWS_REGION || 'us-east-2', +}); + +export const USER_POOL_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; +export const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; + +/** + * How to answer each Cognito auth challenge. + * + * Adding support for a new challenge type is adding a row here -- no routing, + * dispatch or flow changes. That is what makes enabling MFA on the user pool a + * configuration change rather than a code change: SOFTWARE_TOKEN_MFA, SMS_MFA, + * EMAIL_OTP and SELECT_MFA_TYPE are already wired and become reachable the + * moment mfa_configuration is turned on in infrastructure/aws/cognito.tf. + */ +interface ChallengeSpec { + /** Body fields that must be present for this challenge. */ + required: string[]; + /** Builds the Cognito ChallengeResponses map. */ + build: (body: Record, username: string) => Record; +} + +export const CHALLENGE_SPECS: Record = { + NEW_PASSWORD_REQUIRED: { + required: ['newPassword'], + build: (body, username) => ({ + USERNAME: username, + NEW_PASSWORD: String(body.newPassword), + ...(body.name ? { 'userAttributes.name': String(body.name) } : {}), + }), + }, + SOFTWARE_TOKEN_MFA: { + required: ['code'], + build: (body, username) => ({ + USERNAME: username, + SOFTWARE_TOKEN_MFA_CODE: String(body.code), + }), + }, + SMS_MFA: { + required: ['code'], + build: (body, username) => ({ + USERNAME: username, + SMS_MFA_CODE: String(body.code), + }), + }, + EMAIL_OTP: { + required: ['code'], + build: (body, username) => ({ + USERNAME: username, + EMAIL_OTP_CODE: String(body.code), + }), + }, + SELECT_MFA_TYPE: { + required: ['mfaType'], + build: (body, username) => ({ + USERNAME: username, + ANSWER: String(body.mfaType), + }), + }, +}; + +/** + * Password rules, kept in one place so /register and /respond-challenge cannot + * drift. Returns an error message, or null when the password is acceptable. + * Mirrors the pool's password_policy in infrastructure/aws/cognito.tf. + */ +export function validatePassword(password: unknown): string | null { + if (typeof password !== 'string') return 'Password must be a string'; + if (password.length < 8) return 'Password must be at least 8 characters long'; + if (!/[a-z]/.test(password)) return 'Password must contain at least one lowercase letter'; + if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter'; + if (!/[0-9]/.test(password)) return 'Password must contain at least one number'; + return null; +} + +/** 200 + the token set. Shape matches what the frontend AuthContext expects. */ +export function authResultResponse(result: AuthenticationResultType): APIGatewayProxyResult { + return json(200, { + AccessToken: result.AccessToken, + IdToken: result.IdToken, + // Absent on REFRESH_TOKEN_AUTH: Cognito does not re-issue a refresh token. + RefreshToken: result.RefreshToken, + ExpiresIn: result.ExpiresIn, + TokenType: result.TokenType, + }); +} + +/** + * 200 + the challenge to answer next. The opaque Session is valid across + * processes, so the client can complete it with a separate request to + * POST /auth/respond-challenge. + */ +export function challengeResponse( + response: InitiateAuthCommandOutput | RespondToAuthChallengeCommandOutput, +): APIGatewayProxyResult { + return json(200, { + ChallengeName: response.ChallengeName, + Session: response.Session, + ChallengeParameters: response.ChallengeParameters, + message: `Additional authentication step required: ${response.ChallengeName}`, + }); +} + +/** Single Cognito error -> HTTP mapping, shared by login, challenge and refresh. */ +export function mapCognitoAuthError( + error: any, + stage: 'login' | 'challenge' | 'refresh', +): APIGatewayProxyResult { + console.error(`Cognito ${stage} error:`, error); + const code = error?.name; + + switch (code) { + case 'NotAuthorizedException': { + const message = + stage === 'refresh' + ? 'Refresh token is invalid or expired' + : stage === 'challenge' + ? 'Challenge session is invalid or expired, please sign in again' + : 'Invalid email or password'; + return json(401, { message, code }); + } + // prevent_user_existence_errors is ENABLED on the app client, so Cognito + // normally folds this into NotAuthorizedException. Handled for parity. + case 'UserNotFoundException': + return json(401, { message: 'Invalid email or password', code }); + case 'UserNotConfirmedException': + return json(403, { message: 'Email not verified', code }); + case 'PasswordResetRequiredException': + return json(403, { message: 'Password reset required', code }); + case 'CodeMismatchException': + return json(400, { message: 'Invalid verification code', code }); + case 'ExpiredCodeException': + return json(400, { message: 'Verification code has expired', code }); + case 'InvalidPasswordException': + return json(400, { + message: + 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)', + code, + }); + case 'InvalidParameterException': + return json(400, { message: error?.message || 'Invalid parameters provided', code }); + case 'TooManyRequestsException': + case 'LimitExceededException': + case 'TooManyFailedAttemptsException': + return json(429, { message: 'Too many attempts, please try again later', code }); + case 'ForbiddenException': + return json(403, { message: 'Request blocked', code }); + default: + return json(500, { message: 'Authentication failed', error: error?.message, code }); + } +} diff --git a/apps/backend/lambdas/auth/tsconfig.json b/apps/backend/lambdas/auth/tsconfig.json index d35b2baa..c63669f7 100644 --- a/apps/backend/lambdas/auth/tsconfig.json +++ b/apps/backend/lambdas/auth/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts", "services/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } diff --git a/apps/backend/lambdas/donors/Dockerfile b/apps/backend/lambdas/donors/Dockerfile index 55811090..6d566340 100644 --- a/apps/backend/lambdas/donors/Dockerfile +++ b/apps/backend/lambdas/donors/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/donors/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index 30ce2bdc..c2819f43 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -8,12 +8,13 @@ Lambda for managing donors. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /donors/health | Health check | | GET | /donors | | -| POST | /donations | | +| GET | /donors/donations | | +| POST | /donors/donations | | | POST | /donors | | | DELETE | /donors/{id} | | -| DELETE | /donations/{id} | | +| DELETE | /donors/donations/{id} | | ## Setup diff --git a/apps/backend/lambdas/donors/controllers/donations.ts b/apps/backend/lambdas/donors/controllers/donations.ts new file mode 100644 index 00000000..01ce02ab --- /dev/null +++ b/apps/backend/lambdas/donors/controllers/donations.ts @@ -0,0 +1,209 @@ +import type { RouteCtx } from '@branch/lambda-http'; +import { json } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; + +// GET /donors/donations +export async function getDonations({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.project_donations') + .select(db.fn.count('donation_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .orderBy('donation_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donations, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .execute(); + return json(200, { data: donations }); +} + +// POST /donors/donations +export async function createDonation({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const body = event.body ? (JSON.parse(event.body) as Record) : {}; + const { donor_id, project_id, amount, donated_at } = body; + + if (donor_id === undefined || project_id === undefined || amount === undefined) { + return json(400, { message: 'donor_id, project_id, and amount are required' }); + } + + // Optional: the column defaults to now(), so an omitted date still works. + // Accepted so a donation can be backdated to when it was actually received. + let donatedAt: Date | undefined; + if (donated_at !== undefined && donated_at !== null && donated_at !== '') { + if (typeof donated_at !== 'string') { + return json(400, { message: 'donated_at must be a date string' }); + } + const parsed = new Date(donated_at); + if (Number.isNaN(parsed.getTime())) { + return json(400, { message: 'donated_at must be a valid date' }); + } + donatedAt = parsed; + } + // Numeric fields arrive as strings from form posts; amount is NUMERIC(12,2) + const num = (value: unknown) => + typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN; + const donorId = num(donor_id); + const projectId = num(project_id); + const donationAmount = num(amount); + + if (!Number.isInteger(donorId) || donorId < 1) { + return json(400, { message: 'donor_id must be a positive integer' }); + } + if (!Number.isInteger(projectId) || projectId < 1) { + return json(400, { message: 'project_id must be a positive integer' }); + } + if (!isFinite(donationAmount) || donationAmount <= 0) { + return json(400, { message: 'amount must be a positive number' }); + } + // Check user is admin or a member of the project + if (!authContext.user?.isAdmin) { + const userId = authContext.user!.userId as number; + const membership = await db + .selectFrom('branch.project_memberships') + .select('membership_id') + .where('project_id', '=', projectId) + .where('user_id', '=', userId) + .executeTakeFirst(); + + if (!membership) { + return json(403, { message: 'You must be a member' }); + } + } + + // Checked after the membership check so project existence isn't leaked to non-members + const donor = await db + .selectFrom('branch.donors') + .select('donor_id') + .where('donor_id', '=', donorId) + .executeTakeFirst(); + + if (!donor) { + return json(404, { message: 'Donor not found' }); + } + + const project = await db + .selectFrom('branch.projects') + .select('project_id') + .where('project_id', '=', projectId) + .executeTakeFirst(); + + if (!project) { + return json(404, { message: 'Project not found' }); + } + + try { + const donation = await db + .insertInto('branch.project_donations') + .values({ + donor_id: donorId, + project_id: projectId, + amount: donationAmount, + ...(donatedAt ? { donated_at: donatedAt } : {}), + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return json(201, { data: donation }); + } catch (err: any) { + if (err?.code === '23505') { + return json(409, { message: 'A donation from this donor to this project already exists' }); + } + if (err?.code === '23503') { + return json(404, { message: 'Donor or project not found' }); + } + throw err; + } +} + +// DELETE /donors/donations/{id} +export async function deleteDonation({ event, params }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const id = params.id; + if (!id || !/^\d+$/.test(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const donation = await db + .selectFrom('branch.project_donations') + .where('donation_id', '=', Number(id)) + .selectAll() + .executeTakeFirst(); + + if (!donation) { + return json(404, { message: 'Donation not found' }); + } + + if (!authContext.user?.isAdmin) { + const userId = authContext.user!.userId as number; + const membership = await db + .selectFrom('branch.project_memberships') + .select('membership_id') + .where('project_id', '=', donation.project_id) + .where('user_id', '=', userId) + .executeTakeFirst(); + + if (!membership) { + return json(403, { message: 'You must be a member of this project to delete this donation' }); + } + } + + const deleted = await db.deleteFrom('branch.project_donations').where('donation_id', '=', Number(id)).execute(); + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'Donation not found' }); + } + + return json(200, { ok: true, route: 'DELETE /donations/{id}', pathParams: { id } }); +} diff --git a/apps/backend/lambdas/donors/controllers/donors.ts b/apps/backend/lambdas/donors/controllers/donors.ts new file mode 100644 index 00000000..58cdad12 --- /dev/null +++ b/apps/backend/lambdas/donors/controllers/donors.ts @@ -0,0 +1,136 @@ +import type { RouteCtx } from '@branch/lambda-http'; +import { json } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; +import { DonorValidationUtils } from '../validation-utils'; + +// GET /donors +export async function getDonors({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.donors') + .select(db.fn.count('donor_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donors = await db + .selectFrom('branch.donors') + .selectAll() + .orderBy('donor_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donors, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const donors = await db.selectFrom('branch.donors').selectAll().execute(); + return json(200, { data: donors }); +} + +// POST /donors +export async function createDonor({ event }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + + if (!user) { + return json(401, { message: 'Authentication required' }); + } + if (!user.isAdmin) { + return json(403, { message: 'Only admins can create donors' }); + } + + const body = event.body ? (JSON.parse(event.body) as Record) : {}; + + // Validate input + const validationResult = DonorValidationUtils.validateDonorInput(body); + if (validationResult instanceof Error) { + return json(400, { message: validationResult.message }); + } + + const { organization, contactName, contactEmail } = validationResult; + + // Insert donor with authenticated user as entered_by + try { + await db + .insertInto('branch.donors') + .values({ + organization, + contact_name: contactName ?? null, + contact_email: contactEmail ?? null, + }) + .executeTakeFirst(); + } catch (err) { + console.error('Database insert error:', err); + return json(500, { message: 'Failed to create donor' }); + } + + return json(201, { + ok: true, + route: 'POST /donors', + body: { + organization, + contactName: contactName ?? null, + contactEmail: contactEmail ?? null, + }, + }); +} + +// DELETE /donors/{id} +export async function deleteDonor({ event, params }: RouteCtx) { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const id = params.id; + if (!id || !/^\d+$/.test(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + if (!authContext.user?.isAdmin) { + return json(403, { message: 'Only admins can delete donors' }); + } + + const deleted = await db.deleteFrom('branch.donors').where('donor_id', '=', Number(id)).execute(); + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'Donor not found' }); + } + + return json(200, { ok: true, route: 'DELETE /donors/{id}', pathParams: { id } }); +} diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index ff27984c..2f158b0a 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -1,364 +1,4 @@ -import { APIGatewayProxyResult } from 'aws-lambda'; -import db from './db'; -import { authenticateRequest } from './auth'; -import { DonorValidationUtils } from './validation-utils'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /donors[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/donors(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated) { - return json(401, { message: 'Authentication required' }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // GET /donors - if (rawPath === '/' && method === 'GET') { - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.donors') - .select(db.fn.count('donor_id').as('count')) - .executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const donors = await db - .selectFrom('branch.donors') - .selectAll() - .orderBy('donor_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - - return json(200, { - data: donors, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const donors = await db.selectFrom('branch.donors').selectAll().execute(); - return json(200, { data: donors }); - } - - // GET /donations - if ((normalizedPath === '/donations') && method === 'GET') { - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.project_donations') - .select(db.fn.count('donation_id').as('count')) - .executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const donations = await db - .selectFrom('branch.project_donations') - .selectAll() - .orderBy('donation_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - - return json(200, { - data: donations, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const donations = await db - .selectFrom('branch.project_donations') - .selectAll() - .execute(); - return json(200, { data: donations }); - } - - // POST /donations - if (normalizedPath === '/donations' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { donor_id, project_id, amount, donated_at } = body; - - if (donor_id === undefined || project_id === undefined || amount === undefined) { - return json(400, { message: 'donor_id, project_id, and amount are required' }); - } - - // Optional: the column defaults to now(), so an omitted date still works. - // Accepted so a donation can be backdated to when it was actually received. - let donatedAt: Date | undefined; - if (donated_at !== undefined && donated_at !== null && donated_at !== '') { - if (typeof donated_at !== 'string') { - return json(400, { message: 'donated_at must be a date string' }); - } - const parsed = new Date(donated_at); - if (Number.isNaN(parsed.getTime())) { - return json(400, { message: 'donated_at must be a valid date' }); - } - donatedAt = parsed; - } - // Numeric fields arrive as strings from form posts; amount is NUMERIC(12,2) - const num = (value: unknown) => - typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN; - const donorId = num(donor_id); - const projectId = num(project_id); - const donationAmount = num(amount); - - if (!Number.isInteger(donorId) || donorId < 1) { - return json(400, { message: 'donor_id must be a positive integer' }); - } - if (!Number.isInteger(projectId) || projectId < 1) { - return json(400, { message: 'project_id must be a positive integer' }); - } - if (!isFinite(donationAmount) || donationAmount <= 0) { - return json(400, { message: 'amount must be a positive number' }); - } - // Check user is admin or a member of the project - if (!authContext.user?.isAdmin) { - const userId = authContext.user!.userId as number; - const membership = await db - .selectFrom('branch.project_memberships') - .select('membership_id') - .where('project_id', '=', projectId) - .where('user_id', '=', userId) - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'You must be a member' }); - } - } - - // Checked after the membership check so project existence isn't leaked to non-members - const donor = await db - .selectFrom('branch.donors') - .select('donor_id') - .where('donor_id', '=', donorId) - .executeTakeFirst(); - - if (!donor) { - return json(404, { message: 'Donor not found' }); - } - - const project = await db - .selectFrom('branch.projects') - .select('project_id') - .where('project_id', '=', projectId) - .executeTakeFirst(); - - if (!project) { - return json(404, { message: 'Project not found' }); - } - - try { - const donation = await db - .insertInto('branch.project_donations') - .values({ - donor_id: donorId, - project_id: projectId, - amount: donationAmount, - ...(donatedAt ? { donated_at: donatedAt } : {}), - }) - .returningAll() - .executeTakeFirstOrThrow(); - - return json(201, { data: donation }); - } catch (err: any) { - if (err?.code === '23505') { - return json(409, { message: 'A donation from this donor to this project already exists' }); - } - if (err?.code === '23503') { - return json(404, { message: 'Donor or project not found' }); - } - throw err; - } - } - - // POST /donors - if ((normalizedPath === '/' || normalizedPath === '' || normalizedPath === '/donors') && method === 'POST') { - // Authenticate the request - const { user } = authContext; - - if (!user) { - return json(401, { message: 'Authentication required' }); - } - if (!user.isAdmin) { - return json(403, { message: 'Only admins can create donors' }); - } - - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // Validate input - const validationResult = DonorValidationUtils.validateDonorInput(body); - if (validationResult instanceof Error) { - return json(400, { message: validationResult.message }); - } - - const { organization, contactName, contactEmail } = validationResult; - - // Insert donor with authenticated user as entered_by - try { - await db - .insertInto('branch.donors') - .values({ - organization, - contact_name: contactName ?? null, - contact_email: contactEmail ?? null, - }) - .executeTakeFirst(); - } catch (err) { - console.error('Database insert error:', err); - return json(500, { message: 'Failed to create donor' }); - } - - return json(201, { - ok: true, - route: 'POST /donors', - body: { - organization, - contactName: contactName ?? null, - contactEmail: contactEmail ?? null, - }, - }); - } - - // DELETE /donors/{id} - if (/^\/[^\/]+$/.test(normalizedPath) && method === 'DELETE') { - const id = normalizedPath.split('/')[1]; - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - if (!authContext.user?.isAdmin) { - return json(403, { message: 'Only admins can delete donors' }); - } - - const deleted = await db.deleteFrom('branch.donors').where('donor_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Donor not found' }); - } - - return json(200, { ok: true, route: 'DELETE /donors/{id}', pathParams: { id } }); - - } - - // DELETE /donations/{id} - if (normalizedPath.startsWith('/donations/') && normalizedPath.split('/').length === 3 && method === 'DELETE') { - const id = normalizedPath.split('/')[2]; - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - const donation = await db - .selectFrom('branch.project_donations') - .where('donation_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!donation) { - return json(404, { message: 'Donation not found' }); - } - - if (!authContext.user?.isAdmin) { - const userId = authContext.user!.userId as number; - const membership = await db - .selectFrom('branch.project_memberships') - .select('membership_id') - .where('project_id', '=', donation.project_id) - .where('user_id', '=', userId) - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'You must be a member of this project to delete this donation' }); - } - } - - const deleted = await db.deleteFrom('branch.project_donations').where('donation_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Donation not found' }); - } - - return json(200, { ok: true, route: 'DELETE /donations/{id}', pathParams: { id } }); - } - - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +export const handler = (event: any) => dispatch(event, { prefix: 'donors', routes }); diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index 66b5b492..32fcad09 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.17.2" @@ -43,6 +44,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -548,6 +565,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 252aeb23..0e96a436 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.17.2" diff --git a/apps/backend/lambdas/donors/routes.ts b/apps/backend/lambdas/donors/routes.ts new file mode 100644 index 00000000..ae3d6170 --- /dev/null +++ b/apps/backend/lambdas/donors/routes.ts @@ -0,0 +1,14 @@ +import type { Route } from '@branch/lambda-http'; +import { getDonors, createDonor, deleteDonor } from './controllers/donors'; +import { getDonations, createDonation, deleteDonation } from './controllers/donations'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'GET', pattern: '/donors', handler: getDonors }, + { method: 'GET', pattern: '/donors/donations', handler: getDonations }, + { method: 'POST', pattern: '/donors/donations', handler: createDonation }, + { method: 'POST', pattern: '/donors', handler: createDonor }, + { method: 'DELETE', pattern: '/donors/:id', handler: deleteDonor }, + { method: 'DELETE', pattern: '/donors/donations/:id', handler: deleteDonation }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index f78dddcd..88cca0d4 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -204,6 +204,17 @@ describe("Donor API with data", () => { expect(body.data.length).toBe(3); }); + test("GET /donors/donations reaches the donations controller, not the /donors/:id route", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donors/donations')); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(3); + expect(body.data[0]).toHaveProperty('donation_id'); + }); + test("GET /donations with page and limit returns paginated response", async () => { mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); const res = await handler(createEvent('GET', '/donations', undefined, { page: '1', limit: '1' })); diff --git a/apps/backend/lambdas/donors/tsconfig.json b/apps/backend/lambdas/donors/tsconfig.json index 7e7cce09..a7c5d558 100644 --- a/apps/backend/lambdas/donors/tsconfig.json +++ b/apps/backend/lambdas/donors/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts", "jest.config.js"], + "include": ["*.ts", "controllers/**/*.ts", "jest.config.js"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } diff --git a/apps/backend/lambdas/expenditures/Dockerfile b/apps/backend/lambdas/expenditures/Dockerfile index 40e719df..b34ac87d 100644 --- a/apps/backend/lambdas/expenditures/Dockerfile +++ b/apps/backend/lambdas/expenditures/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/expenditures/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 0f23e38a..0b434671 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -8,8 +8,9 @@ Lambda for tracking project expenditures. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /expenditures/health | Health check | | GET | /expenditures | | +| POST | /expenditures | | | GET | /expenditures/upload-url | | | GET | /expenditures/{id}/receipt | | | GET | /expenditures/{id} | | diff --git a/apps/backend/lambdas/expenditures/controllers/expenditures.ts b/apps/backend/lambdas/expenditures/controllers/expenditures.ts new file mode 100644 index 00000000..c5dc72f6 --- /dev/null +++ b/apps/backend/lambdas/expenditures/controllers/expenditures.ts @@ -0,0 +1,325 @@ +import type { RouteHandler } from '@branch/lambda-http'; +import { json, requireAuth } from '@branch/lambda-http'; +import { authenticateRequest } from '../auth'; +import { ExpenditureValidationUtils } from '../validation-utils'; +import * as expendituresService from '../services/expenditures'; + +function invalidId(id: string): boolean { + return !/^\d+$/.test(id) || parseInt(id, 10) < 1; +} + +// GET /expenditures +export const getExpenditures: RouteHandler = async ({ event }) => { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + const projectIdStr = queryParams.projectId as string | undefined; + + if (pageStr !== undefined && (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1)) { + return json(400, { message: 'page must be a positive integer' }); + } + + if (limitStr !== undefined && (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1)) { + return json(400, { message: 'limit must be a positive integer' }); + } + + if (projectIdStr !== undefined && (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1)) { + return json(400, { message: 'projectId must be a positive integer' }); + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + const totalItems = await expendituresService.countExpenditures(projectId); + const totalPages = Math.ceil(totalItems / limit); + const expenditures = await expendituresService.queryExpenditures(projectId, { limit, offset }); + + return json(200, { + data: expenditures, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const expenditures = await expendituresService.queryExpenditures(projectId); + return json(200, { data: expenditures }); +}; + +// POST /expenditures +export const createExpenditure: RouteHandler = async ({ event }) => { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + const body = event.body ? JSON.parse(event.body) as Record : {}; + + const validationResult = ExpenditureValidationUtils.validateExpenditureInput(body); + if (validationResult instanceof Error) { + return json(400, { message: validationResult.message }); + } + + const { projectID, amount, category, description, status, receiptUrl, spentOn } = validationResult; + + // Authorize: must be global admin, or Director/Admin on this project + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(projectID, user.userId!); + if (!membership || !['Director', 'Admin'].includes(membership.role)) { + return json(403, { message: 'Unable to create expenditure for this project' }); + } + } + + const project = await expendituresService.findProjectById(projectID); + if (!project) { + return json(404, { message: 'Project not found' }); + } + + try { + await expendituresService.insertExpenditure({ + project_id: projectID, + entered_by: user.userId!, + amount, + category: category ?? null, + description: description ?? null, + status, + receipt_url: receiptUrl ?? null, + spent_on: spentOn ? new Date(spentOn) : new Date(), + }); + } catch (err) { + console.error('Database insert error:', err); + return json(500, { message: 'Failed to create expenditure' }); + } + + return json(201, { + ok: true, + route: 'POST /expenditures', + body: { + projectID, + enteredBy: user.userId!, + amount, + category: category ?? null, + description: description ?? null, + status, + receiptUrl: receiptUrl ?? null, + spentOn: spentOn ?? new Date().toISOString().split('T')[0], + }, + }); +}; + +// GET /expenditures/upload-url — presigned PUT for a receipt PDF. +export const getUploadUrl: RouteHandler = async ({ event }) => { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const queryParams = event.queryStringParameters || {}; + const { fileName, projectId: projectIdStr } = queryParams; + + if (!fileName || typeof fileName !== 'string') { + return json(400, { message: 'fileName is required' }); + } + if (fileName.split('.').pop()?.toLowerCase() !== 'pdf') { + return json(400, { message: 'Only PDF receipts are supported' }); + } + if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + const projectId = parseInt(projectIdStr, 10); + + // Same authorization as POST /expenditures: you may only attach a receipt + // to a project you are allowed to file an expenditure against. + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(projectId, user.userId!); + if (!membership || !['Director', 'Admin'].includes(membership.role)) { + return json(403, { message: 'Unable to upload a receipt for this project' }); + } + } + + const { uploadUrl, objectUrl } = await expendituresService.presignUploadUrl(projectId, fileName); + return json(200, { uploadUrl, objectUrl }); +}; + +// GET /expenditures/{id}/receipt — presigned GET so the receipt can be read +// without the bucket being public. +export const getReceipt: RouteHandler = async ({ event, params }) => { + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) return json(404, { message: 'Expenditure not found' }); + + // Mirrors GET /expenditures/{id}: admin, or any membership on the project. + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(expenditure.project_id, user.userId!); + if (!membership) { + return json(403, { message: 'Unable to view this receipt' }); + } + } + + if (!expenditure.receipt_url) { + return json(404, { message: 'Expenditure has no receipt' }); + } + + const key = expendituresService.receiptKeyFromUrl(expenditure.receipt_url); + if (!key) { + return json(422, { message: 'Receipt is not stored in the receipts bucket' }); + } + + const downloadUrl = await expendituresService.presignReceiptDownload(key); + + return json(200, { + downloadUrl, + fileName: key.split('/').pop(), + }); +}; + +// GET /expenditures/{id} +export const getExpenditureById: RouteHandler = async ({ event, params }) => { + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) return json(404, { message: 'Expenditure not found' }); + + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(expenditure.project_id, user.userId!); + if (!membership) { + return json(403, { message: 'Unable to view this expenditure' }); + } + } + + // "Submitted By" in the review modal needs a name, not an id. + const submitter = expenditure.entered_by + ? await expendituresService.findUserName(expenditure.entered_by) + : undefined; + + const projectName = await expendituresService.findProjectName(expenditure.project_id); + + return json(200, { + ok: true, + route: 'GET /expenditures/{id}', + pathParams: { id }, + body: { + expenditureId: expenditure.expenditure_id, + projectId: expenditure.project_id, + projectName: projectName ?? null, + enteredBy: expenditure.entered_by, + submittedByName: submitter ?? null, + amount: expenditure.amount, + category: expenditure.category, + description: expenditure.description, + status: expenditure.status, + adminNotes: expenditure.admin_notes, + receiptUrl: expenditure.receipt_url, + spent_on: expenditure.spent_on, + createdAt: expenditure.created_at, + }, + }); +}; + +// DELETE /expenditures/{id} +export const deleteExpenditure: RouteHandler = async ({ event, params }) => { + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) { + return json(404, { message: 'Expenditure not found' }); + } + + // (mirrors POST endpoint) Authorize: must be global admin, or Director/Admin on this expenditure's project + if (!user.isAdmin) { + const membership = await expendituresService.findMembership(expenditure.project_id, user.userId!); + if (!membership || !['Director', 'Admin'].includes(membership.role)) { + return json(403, { message: 'Unable to delete this expenditure' }); + } + } + + const numDeletedRows = await expendituresService.deleteExpenditureById(Number(id)); + if (numDeletedRows === 0n) { + return json(404, { message: 'Expenditure not found' }); + } + + // After the row, never before: if the object went first and the delete + // below failed, the receipt would be gone with a row still pointing at it. + const receiptDeleted = await expendituresService.deleteReceiptObject(expenditure.receipt_url); + + return json(200, { ok: true, route: 'DELETE /expenditures/{id}', pathParams: { id }, receiptDeleted }); +}; + +// PATCH /expenditures/{id}/status — approve/decline (admin only) +export const patchExpenditureStatus: RouteHandler = async ({ event, params }) => { + const authContext = await authenticateRequest(event); + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + + const { id } = params; + if (invalidId(id)) { + return json(400, { message: 'id must be a positive integer' }); + } + + const body = event.body ? JSON.parse(event.body) as Record : {}; + + const statusResult = ExpenditureValidationUtils.validateApprovalStatus(body.status); + if (statusResult instanceof Error) { + return json(400, { message: statusResult.message }); + } + + const adminNotesResult = ExpenditureValidationUtils.validateAdminNotes(body.adminNotes); + if (adminNotesResult instanceof Error) { + return json(400, { message: adminNotesResult.message }); + } + + const expenditure = await expendituresService.findExpenditureById(Number(id)); + if (!expenditure) { + return json(404, { message: 'Expenditure not found' }); + } + + await expendituresService.updateExpenditureStatus(Number(id), statusResult, adminNotesResult); + const updated = await expendituresService.findExpenditureById(Number(id)); + + return json(200, { + ok: true, + route: 'PATCH /expenditures/{id}/status', + pathParams: { id }, + body: { + expenditureId: updated!.expenditure_id, + status: updated!.status, + adminNotes: updated!.admin_notes, + }, + }); +}; diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index d562e4b2..3acdcab8 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -1,539 +1,4 @@ -import { APIGatewayProxyResult } from 'aws-lambda'; -import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import db from './db'; -import { ExpenditureValidationUtils } from './validation-utils'; -import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -const REGION = process.env.AWS_REGION ?? 'us-east-2'; -const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; -const s3 = new S3Client({ region: REGION }); - -// Receipts are PDFs only, matching the dropzone in AddExpenseModal. -const RECEIPT_CONTENT_TYPE = 'application/pdf'; - -// Receipts live in the same bucket as reports, under their own prefix. -function receiptKeyFromUrl(objectUrl: string): string | null { - const match = objectUrl.match(/^https:\/\/[^/]+\/(receipts\/.+)$/); - return match ? decodeURIComponent(match[1]) : null; -} - -/** - * Best-effort removal of the receipt behind a deleted expenditure. - * - * Deliberately never throws: the row is already gone by the time this runs, and - * the caller must not turn a successful delete into a 500 because S3 was - * unreachable or the role is missing `s3:DeleteObject`. A leftover object is - * recoverable; a row that cannot be deleted is not. - */ -async function deleteReceiptObject(receiptUrl: string | null): Promise { - if (!receiptUrl) return true; - const key = receiptKeyFromUrl(receiptUrl); - if (!key) return false; - // Read at call time rather than using the module-level BUCKET: the value is - // then observable to callers that set it after import, which is what the - // unit tests do. - const bucket = process.env.REPORTS_BUCKET_NAME ?? ''; - if (!bucket) { - console.error('REPORTS_BUCKET_NAME is not set; leaving receipt object', key); - return false; - } - try { - await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); - return true; - } catch (err) { - console.error('Failed to delete receipt object', key, err); - return false; - } -} - -function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { - const authCheck = checkAuthorization(authContext, level, resourceUserId); - if (!authCheck.allowed) { - return authContext.isAuthenticated - ? json(403, { message: authCheck.reason || 'Forbidden' }) - : json(401, { message: 'Authentication required' }); - } -} - -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /expenditures[/{proxy+}]; strip the - // mount prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/expenditures(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // GET /expenditures - if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated) { - return json(401, { message: 'Authentication required' }); - } - - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - const projectIdStr = queryParams.projectId as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - if (projectIdStr !== undefined) { - if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); - - return json(200, { - data: expenditures, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); - - return json(200, { data: expenditures }); - } - - // POST /expenditures - if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { - // Authenticate the request - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const { user } = authContext; - - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // Validate input - const validationResult = ExpenditureValidationUtils.validateExpenditureInput(body); - if (validationResult instanceof Error) { - return json(400, { message: validationResult.message }); - } - - const { projectID, amount, category, description, status, receiptUrl, spentOn } = validationResult; - - // Authorize: must be global admin, or Director/Admin on this project - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', projectID) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership || !['Director', 'Admin'].includes(membership.role)) { - return json(403, { message: 'Unable to create expenditure for this project' }); - } - } - - // Check if project exists - const project = await db - .selectFrom('branch.projects') - .where('project_id', '=', projectID) - .selectAll() - .executeTakeFirst(); - - if (!project) { - return json(404, { message: 'Project not found' }); - } - - // Insert expenditure with authenticated user as entered_by - try { - await db - .insertInto('branch.expenditures') - .values({ - project_id: projectID, - entered_by: user.userId!, - amount, - category: category ?? null, - description: description ?? null, - status, - receipt_url: receiptUrl ?? null, - spent_on: spentOn ? new Date(spentOn) : new Date(), - }) - .executeTakeFirst(); - } catch (err) { - console.error('Database insert error:', err); - return json(500, { message: 'Failed to create expenditure' }); - } - - return json(201, { - ok: true, - route: 'POST /expenditures', - body: { - projectID, - enteredBy: user.userId!, - amount, - category: category ?? null, - description: description ?? null, - status, - receiptUrl: receiptUrl ?? null, - spentOn: spentOn ?? new Date().toISOString().split('T')[0], - }, - }); - } - - // GET /expenditures/upload-url — presigned PUT for a receipt PDF. - // Must be matched before GET /expenditures/{id}, which also matches one segment. - if ((normalizedPath === '/expenditures/upload-url' || normalizedPath === '/upload-url') && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - const { user } = authContext; - - const queryParams = event.queryStringParameters || {}; - const { fileName, projectId: projectIdStr } = queryParams; - - if (!fileName || typeof fileName !== 'string') { - return json(400, { message: 'fileName is required' }); - } - if (fileName.split('.').pop()?.toLowerCase() !== 'pdf') { - return json(400, { message: 'Only PDF receipts are supported' }); - } - if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - const projectId = parseInt(projectIdStr, 10); - - // Same authorization as POST /expenditures: you may only attach a receipt - // to a project you are allowed to file an expenditure against. - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', projectId) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership || !['Director', 'Admin'].includes(membership.role)) { - return json(403, { message: 'Unable to upload a receipt for this project' }); - } - } - - const key = `receipts/${projectId}/${Date.now()}-${fileName}`; - const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - ContentType: RECEIPT_CONTENT_TYPE, - }), { expiresIn: 3600 }); - - return json(200, { - uploadUrl, - objectUrl: `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`, - }); - } - - // GET /expenditures/{id}/receipt — presigned GET so the receipt can be read - // without the bucket being public. - const receiptSegments = normalizedPath.split('/').filter(Boolean); - if (receiptSegments.length >= 2 && receiptSegments[receiptSegments.length - 1] === 'receipt' && method === 'GET') { - const id = receiptSegments[receiptSegments.length - 2]; - if (!/^\d+$/.test(id) || parseInt(id, 10) < 1) { - return json(400, { message: 'id must be a positive integer' }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - const { user } = authContext; - - const expenditure = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!expenditure) return json(404, { message: 'Expenditure not found' }); - - // Mirrors GET /expenditures/{id}: admin, or any membership on the project. - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', expenditure.project_id) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'Unable to view this receipt' }); - } - } - - if (!expenditure.receipt_url) { - return json(404, { message: 'Expenditure has no receipt' }); - } - - const key = receiptKeyFromUrl(expenditure.receipt_url); - if (!key) { - return json(422, { message: 'Receipt is not stored in the receipts bucket' }); - } - - const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({ - Bucket: BUCKET, - Key: key, - }), { expiresIn: 300 }); - - return json(200, { - downloadUrl, - fileName: key.split('/').pop(), - }); - } - - // GET /expenditures/{id} - if (/^\/[^\/]+$/.test(normalizedPath) && method === 'GET') { - const id = normalizedPath.split('/')[1]; - if (!id) return json(400, { message: 'id is required' }); - - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const { user } = authContext; - - const expenditure = await db.selectFrom("branch.expenditures").where("expenditure_id", "=", Number(id)).selectAll().executeTakeFirst(); - if (!expenditure) return json(404, { message: 'Expenditure not found' }); - - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', expenditure.project_id) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership) { - return json(403, { message: 'Unable to view this expenditure' }); - } - } - - // "Submitted By" in the review modal needs a name, not an id. - const submitter = expenditure.entered_by - ? await db - .selectFrom('branch.users') - .where('user_id', '=', expenditure.entered_by) - .select(['name']) - .executeTakeFirst() - : undefined; - - const project = await db - .selectFrom('branch.projects') - .where('project_id', '=', expenditure.project_id) - .select(['name']) - .executeTakeFirst(); - - return json(200, { - ok: true, - route: 'GET /expenditures/{id}', - pathParams: { id }, - body: { - expenditureId: expenditure.expenditure_id, - projectId: expenditure.project_id, - projectName: project?.name ?? null, - enteredBy: expenditure.entered_by, - submittedByName: submitter?.name ?? null, - amount: expenditure.amount, - category: expenditure.category, - description: expenditure.description, - status: expenditure.status, - adminNotes: expenditure.admin_notes, - receiptUrl: expenditure.receipt_url, - spent_on: expenditure.spent_on, - createdAt: expenditure.created_at, - } - }); - } - - // DELETE /expenditures/{id} - if (/^\/[^\/]+$/.test(normalizedPath) && method === 'DELETE') { - const id = normalizedPath.split('/')[1]; - if (!id) return json(400, { message: 'id is required' }); - if (!id || !/^\d+$/.test(id)) { - return json(400, { message: 'id must be a positive integer' }); - } - - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - const { user } = authContext; - - const expenditure = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!expenditure) { - return json(404, { message: 'Expenditure not found' }); - } - - // (mirrors POST endpoint) Authorize: must be global admin, or Director/Admin on this expenditure's project - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', expenditure.project_id) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership || !['Director', 'Admin'].includes(membership.role)) { - return json(403, { message: 'Unable to delete this expenditure' }); - } - } - - const deleted = await db.deleteFrom('branch.expenditures').where('expenditure_id', '=', Number(id)).execute(); - - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Expenditure not found' }); - } - - // After the row, never before: if the object went first and the delete - // below failed, the receipt would be gone with a row still pointing at it. - const receiptDeleted = await deleteReceiptObject(expenditure.receipt_url); - - return json(200, { ok: true, route: 'DELETE /expenditures/{id}', pathParams: { id }, receiptDeleted }); - } - - // PATCH /expenditures/{id}/status — approve/decline (admin only) - // (dev server strips the /expenditures prefix, so match the trailing /{id}/status) - const statusSegments = normalizedPath.split('/').filter(Boolean); - if ((statusSegments.length >= 2 && statusSegments[statusSegments.length - 1] === 'status') && method === 'PATCH') { - const authContext = await authenticateRequest(event); - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const id = statusSegments[statusSegments.length - 2]; - if (!/^\d+$/.test(id) || parseInt(id, 10) < 1) { - return json(400, { message: 'id must be a positive integer' }); - } - - const body = event.body ? JSON.parse(event.body) as Record : {}; - - const statusResult = ExpenditureValidationUtils.validateApprovalStatus(body.status); - if (statusResult instanceof Error) { - return json(400, { message: statusResult.message }); - } - - const adminNotesResult = ExpenditureValidationUtils.validateAdminNotes(body.adminNotes); - if (adminNotesResult instanceof Error) { - return json(400, { message: adminNotesResult.message }); - } - - // make sure expenditure exists - const expenditure = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!expenditure) { - return json(404, { message: 'Expenditure not found' }); - } - - // update - await db - .updateTable('branch.expenditures') - .set( - adminNotesResult === undefined - ? { status: statusResult } - : { status: statusResult, admin_notes: adminNotesResult }, - ) - .where('expenditure_id', '=', Number(id)) - .execute(); - - // get updated expenditure - const updated = await db - .selectFrom('branch.expenditures') - .where('expenditure_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - return json(200, { - ok: true, - route: 'PATCH /expenditures/{id}/status', - pathParams: { id }, - body: { - expenditureId: updated!.expenditure_id, - status: updated!.status, - adminNotes: updated!.admin_notes, - }, - }); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} \ No newline at end of file +export const handler = (event: any) => dispatch(event, { prefix: 'expenditures', routes }); diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index 5001a1ca..9e1618cc 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "kysely": "^0.28.8", @@ -48,6 +49,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -878,6 +895,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index e9f9896f..d24c0a25 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -29,6 +29,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/expenditures/routes.ts b/apps/backend/lambdas/expenditures/routes.ts new file mode 100644 index 00000000..86882e05 --- /dev/null +++ b/apps/backend/lambdas/expenditures/routes.ts @@ -0,0 +1,23 @@ +import type { Route } from '@branch/lambda-http'; +import { + getExpenditures, + createExpenditure, + getUploadUrl, + getReceipt, + getExpenditureById, + deleteExpenditure, + patchExpenditureStatus, +} from './controllers/expenditures'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'GET', pattern: '/expenditures', handler: getExpenditures }, + { method: 'POST', pattern: '/expenditures', handler: createExpenditure }, + // /expenditures/upload-url must precede /expenditures/:id — both are one segment. + { method: 'GET', pattern: '/expenditures/upload-url', handler: getUploadUrl }, + { method: 'GET', pattern: '/expenditures/:id/receipt', handler: getReceipt }, + { method: 'GET', pattern: '/expenditures/:id', handler: getExpenditureById }, + { method: 'DELETE', pattern: '/expenditures/:id', handler: deleteExpenditure }, + { method: 'PATCH', pattern: '/expenditures/:id/status', handler: patchExpenditureStatus }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/expenditures/services/expenditures.ts b/apps/backend/lambdas/expenditures/services/expenditures.ts new file mode 100644 index 00000000..05b9833a --- /dev/null +++ b/apps/backend/lambdas/expenditures/services/expenditures.ts @@ -0,0 +1,134 @@ +import { Insertable } from 'kysely'; +import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { DB } from '@branch/types'; +import db from '../db'; +import type { ExpenditureStatus } from '../validation-utils'; + +const REGION = process.env.AWS_REGION ?? 'us-east-2'; +const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; +const s3 = new S3Client({ region: REGION }); + +// Receipts are PDFs only, matching the dropzone in AddExpenseModal. +export const RECEIPT_CONTENT_TYPE = 'application/pdf'; + +// Receipts live in the same bucket as reports, under their own prefix. +export function receiptKeyFromUrl(objectUrl: string): string | null { + const match = objectUrl.match(/^https:\/\/[^/]+\/(receipts\/.+)$/); + return match ? decodeURIComponent(match[1]) : null; +} + +/** + * Best-effort removal of the receipt behind a deleted expenditure. + * + * Deliberately never throws: the row is already gone by the time this runs, and + * the caller must not turn a successful delete into a 500 because S3 was + * unreachable or the role is missing `s3:DeleteObject`. A leftover object is + * recoverable; a row that cannot be deleted is not. + */ +export async function deleteReceiptObject(receiptUrl: string | null): Promise { + if (!receiptUrl) return true; + const key = receiptKeyFromUrl(receiptUrl); + if (!key) return false; + // Read at call time rather than using the module-level BUCKET: the value is + // then observable to callers that set it after import, which is what the + // unit tests do. + const bucket = process.env.REPORTS_BUCKET_NAME ?? ''; + if (!bucket) { + console.error('REPORTS_BUCKET_NAME is not set; leaving receipt object', key); + return false; + } + try { + await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + return true; + } catch (err) { + console.error('Failed to delete receipt object', key, err); + return false; + } +} + +export async function countExpenditures(projectId: number | null): Promise { + const totalCount = projectId !== null + ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst() + : await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); + + return Number(totalCount?.count || 0); +} + +export async function queryExpenditures(projectId: number | null, page?: { limit: number; offset: number }) { + if (page) { + return projectId !== null + ? db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(page.limit).offset(page.offset).execute() + : db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(page.limit).offset(page.offset).execute(); + } + + return projectId !== null + ? db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute() + : db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); +} + +export async function findMembership(projectId: number, userId: number) { + return db + .selectFrom('branch.project_memberships') + .where('project_id', '=', projectId) + .where('user_id', '=', userId) + .select('role') + .executeTakeFirst(); +} + +export async function findProjectById(projectId: number) { + return db.selectFrom('branch.projects').where('project_id', '=', projectId).selectAll().executeTakeFirst(); +} + +export async function findProjectName(projectId: number): Promise { + const row = await db.selectFrom('branch.projects').where('project_id', '=', projectId).select(['name']).executeTakeFirst(); + return row?.name; +} + +export async function findUserName(userId: number): Promise { + const row = await db.selectFrom('branch.users').where('user_id', '=', userId).select(['name']).executeTakeFirst(); + return row?.name; +} + +export async function insertExpenditure(values: Insertable): Promise { + await db.insertInto('branch.expenditures').values(values).executeTakeFirst(); +} + +export async function findExpenditureById(id: number) { + return db.selectFrom('branch.expenditures').where('expenditure_id', '=', id).selectAll().executeTakeFirst(); +} + +export async function deleteExpenditureById(id: number): Promise { + const deleted = await db.deleteFrom('branch.expenditures').where('expenditure_id', '=', id).execute(); + return deleted[0]?.numDeletedRows ?? 0n; +} + +export async function updateExpenditureStatus( + id: number, + status: ExpenditureStatus, + adminNotes: string | undefined, +): Promise { + await db + .updateTable('branch.expenditures') + .set(adminNotes === undefined ? { status } : { status, admin_notes: adminNotes }) + .where('expenditure_id', '=', id) + .execute(); +} + +export async function presignUploadUrl(projectId: number, fileName: string): Promise<{ uploadUrl: string; objectUrl: string }> { + const key = `receipts/${projectId}/${Date.now()}-${fileName}`; + const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + ContentType: RECEIPT_CONTENT_TYPE, + }), { expiresIn: 3600 }); + + return { + uploadUrl, + objectUrl: `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`, + }; +} + +export async function presignReceiptDownload(key: string): Promise { + return getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), { expiresIn: 300 }); +} diff --git a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts index 6df50580..cf84c7f5 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts @@ -1140,6 +1140,19 @@ describe('GET /expenditures/upload-url unit tests', () => { expect(json.objectUrl).toContain('receipt.pdf'); }); + test('route precedence: /expenditures/upload-url reaches the upload-url controller, not /expenditures/:id', async () => { + // If route order regressed, this would hit the :id controller with id="upload-url" + // and 400 on the digit check instead of presigning. + const res = await handler(uploadUrlEvent({ fileName: 'receipt.pdf', projectId: '1' })); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json).toHaveProperty('uploadUrl'); + expect(json).toHaveProperty('objectUrl'); + expect(json).not.toHaveProperty('route'); + expect(mockDb.selectFrom).not.toHaveBeenCalledWith('branch.expenditures'); + }); + test('400: non-PDF is rejected', async () => { const res = await handler(uploadUrlEvent({ fileName: 'receipt.png', projectId: '1' })); diff --git a/apps/backend/lambdas/expenditures/tsconfig.json b/apps/backend/lambdas/expenditures/tsconfig.json index d35b2baa..c63669f7 100644 --- a/apps/backend/lambdas/expenditures/tsconfig.json +++ b/apps/backend/lambdas/expenditures/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts", "services/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } diff --git a/apps/backend/lambdas/reports/Dockerfile b/apps/backend/lambdas/reports/Dockerfile index 25f88a0f..2e2bd306 100644 --- a/apps/backend/lambdas/reports/Dockerfile +++ b/apps/backend/lambdas/reports/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/reports/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/reports/README.md b/apps/backend/lambdas/reports/README.md index f02cd638..0c3736c5 100644 --- a/apps/backend/lambdas/reports/README.md +++ b/apps/backend/lambdas/reports/README.md @@ -8,7 +8,7 @@ TODO: Add a description of the reports lambda. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /reports/health | Health check | | POST | /reports/generate | | | GET | /reports | | | GET | /reports/upload-url | | diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts new file mode 100644 index 00000000..f526182b --- /dev/null +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -0,0 +1,367 @@ +import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { json, parseBody, createAuthGuard } from '@branch/lambda-http'; +import type { RouteHandler } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; +import { + checkProjectAccess, + fetchReportData, + generatePdf, + generateDocx, + uploadToS3, + saveReportRecord, + objectUrlFor, + keyFromObjectUrl, + reportKeyPrefix, +} from '../report-service'; + +const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); +const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; + +const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const; +const MIME_TYPES: Record = { + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +}; +const REPORT_TYPES = ['technical', 'narrative'] as const; +const DOWNLOAD_URL_TTL_SECONDS = 900; + +type FileType = typeof ALLOWED_EXTENSIONS[number]; +type ReportType = typeof REPORT_TYPES[number]; + +const guard = createAuthGuard(authenticateRequest); + +// Numeric-only id, mirroring the old REPORT_ID_ROUTE/REPORT_DOWNLOAD_ROUTE regexes +// so a non-numeric :id falls through to the same 404 as an unmatched route. +/** + * Best-effort removal of the generated file behind a deleted report. + * + * Deliberately never throws: the row is already gone by the time this runs, and + * the caller must not turn a successful delete into a 500 because S3 was + * unreachable or the role is missing `s3:DeleteObject`. A leftover object is + * recoverable; a row that cannot be deleted is not. + */ +async function deleteReportObject(objectUrl: string | null): Promise { + if (!objectUrl) return true; + const key = keyFromObjectUrl(objectUrl); + if (!key) return false; + // Read at call time rather than using the module-level BUCKET: the value is + // then observable to callers that set it after import, which is what the + // unit tests do. + const bucket = process.env.REPORTS_BUCKET_NAME ?? ''; + if (!bucket) { + console.error('REPORTS_BUCKET_NAME is not set; leaving report object', key); + return false; + } + try { + await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + return true; + } catch (err) { + console.error('Failed to delete report object', key, err); + return false; + } +} + +function notFoundUnlessNumericId(id: string, path: string, method: string) { + return /^\d+$/.test(id) ? undefined : json(404, { message: 'Not Found', path, method }); +} + +export const generateReport: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const body = event.body ? JSON.parse(event.body) as Record : {}; + + const projectId = body.project_id; + if (projectId === undefined || projectId === null) { + return json(400, { message: 'project_id is required' }); + } + if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { + return json(400, { message: 'project_id must be a positive integer' }); + } + + const fileType = (body.file_type ?? 'pdf') as FileType; + if (!ALLOWED_EXTENSIONS.includes(fileType)) { + return json(400, { message: `file_type must be one of: ${ALLOWED_EXTENSIONS.join(', ')}` }); + } + + const reportType = (body.report_type ?? 'technical') as ReportType; + if (!REPORT_TYPES.includes(reportType)) { + return json(400, { message: `report_type must be one of: ${REPORT_TYPES.join(', ')}` }); + } + + const reportData = await fetchReportData(projectId); + if (!reportData) { + return json(404, { message: 'Project not found' }); + } + + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); + if (!hasAccess) { + return json(403, { message: 'You do not have access to generate reports for this project' }); + } + + let fileBuffer: Buffer; + try { + fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); + } catch (err) { + console.error('Report generation error:', err); + return json(500, { message: 'Failed to generate report' }); + } + + let objectUrl: string; + try { + objectUrl = await uploadToS3(fileBuffer, projectId, fileType); + } catch (err) { + console.error('S3 upload error:', err); + return json(500, { message: 'Failed to upload report' }); + } + + const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; + const record = await saveReportRecord(projectId, objectUrl, title, reportType); + + return json(201, { + ok: true, + report_id: record.report_id, + object_url: record.object_url, + report_type: record.report_type, + file_type: fileType, + }); +}; + +export const listReports: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + const projectIdStr = queryParams.projectId as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + if (projectIdStr !== undefined) { + if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst() + : await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const reports = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute() + : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); + + return json(200, { + data: reports, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const reports = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute() + : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); + + return json(200, { data: reports }); +}; + +export const getUploadUrl: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const queryParams = event.queryStringParameters || {}; + const { fileName, projectId: projectIdStr } = queryParams; + + if (!fileName || typeof fileName !== 'string') { + return json(400, { message: 'fileName is required' }); + } + const safeFileName = fileName.replace(/^.*[\\/]/, '').replace(/[^A-Za-z0-9._-]/g, '_'); + if (!/[A-Za-z0-9]/.test(safeFileName)) { + return json(400, { message: 'Invalid fileName' }); + } + const ext = safeFileName.split('.').pop()?.toLowerCase() ?? ''; + if (!ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) { + return json(400, { message: 'Only PDF and DOCX files are supported' }); + } + if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + const projectId = parseInt(projectIdStr, 10); + + const projectExists = await db.selectFrom('branch.projects') + .where('project_id', '=', projectId) + .select('project_id') + .executeTakeFirst(); + if (!projectExists) return json(404, { message: 'Project not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to upload reports for this project' }); + } + + const key = `${reportKeyPrefix(projectId)}${Date.now()}-${safeFileName}`; + const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + ContentType: MIME_TYPES[ext], + }), { expiresIn: 3600 }); + + return json(200, { uploadUrl, objectUrl: objectUrlFor(key) }); +}; + +export const createReport: RouteHandler = async ({ event }) => { + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const body = parseBody(event); + if (body === null) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { title, projectId, objectUrl, reportType } = body; + + if (!title || typeof title !== 'string' || title.trim().length === 0) { + return json(400, { message: 'title is required' }); + } + if (!projectId || typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + if (!objectUrl || typeof objectUrl !== 'string') { + return json(400, { message: 'objectUrl is required' }); + } + const postedKey = keyFromObjectUrl(objectUrl); + if (!postedKey) { + return json(400, { message: 'objectUrl must point at the reports bucket' }); + } + const resolvedReportType: ReportType = (reportType && REPORT_TYPES.includes(reportType as ReportType)) ? reportType as ReportType : 'technical'; + + const projectExists = await db.selectFrom('branch.projects') + .where('project_id', '=', projectId as number) + .select('project_id') + .executeTakeFirst(); + if (!projectExists) return json(404, { message: 'Project not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, projectId as number, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to upload reports for this project' }); + } + + // Checked after authorization: the key must sit under this project's prefix, + // or a caller with access to one project could register another project's + // object and then read it back through GET /reports/{id}/download. + if (!postedKey.startsWith(reportKeyPrefix(projectId))) { + return json(400, { message: "objectUrl must point at this project's prefix in the reports bucket" }); + } + + const report = await db + .insertInto('branch.reports') + .values({ project_id: projectId, title: (title as string).trim(), object_url: objectUrl as string, report_type: resolvedReportType }) + .returningAll() + .executeTakeFirst(); + + return json(201, report); +}; + +export const downloadReport: RouteHandler = async ({ event, params, path, method }) => { + const notFound = notFoundUnlessNumericId(params.id, path, method); + if (notFound) return notFound; + const id = params.id; + + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to this report' }); + } + + const key = keyFromObjectUrl(report.object_url); + if (!key || !key.startsWith(reportKeyPrefix(report.project_id))) { + return json(409, { message: 'Report is not stored in the reports bucket' }); + } + + const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + }), { expiresIn: DOWNLOAD_URL_TTL_SECONDS }); + + return json(200, { downloadUrl, expiresIn: DOWNLOAD_URL_TTL_SECONDS }); +}; + +export const getReport: RouteHandler = async ({ event, params, path, method }) => { + const notFound = notFoundUnlessNumericId(params.id, path, method); + if (notFound) return notFound; + const id = params.id; + + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to this report' }); + } + + return json(200, { ok: true, route: 'GET /reports/{id}', pathParams: { id }, body: report }); +}; + +export const deleteReport: RouteHandler = async ({ event, params, path, method }) => { + const notFound = notFoundUnlessNumericId(params.id, path, method); + if (notFound) return notFound; + const id = params.id; + + const auth = await guard(event); + if (auth.response) return auth.response; + const user = auth.ctx.user!; + + const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to delete this report' }); + } + + const deleted = await db.deleteFrom('branch.reports').where('report_id', '=', Number(id)).execute(); + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'Report not found' }); + } + + // After the row, never before: if the file went first and this delete + // failed, the report would be gone with a row still pointing at it. + const fileDeleted = await deleteReportObject(report.object_url); + + return json(200, { ok: true, route: 'DELETE /reports/{id}', pathParams: { id }, fileDeleted }); +}; diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 2cba7d23..05071e06 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -1,417 +1,4 @@ -import { APIGatewayProxyResult } from 'aws-lambda'; -import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import db from './db'; -import { authenticateRequest } from './auth'; -import { - checkProjectAccess, - fetchReportData, - generatePdf, - generateDocx, - uploadToS3, - saveReportRecord, - objectUrlFor, - keyFromObjectUrl, - reportKeyPrefix, -} from './report-service'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); -const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; - -const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const; -const MIME_TYPES: Record = { - pdf: 'application/pdf', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', -}; -const REPORT_TYPES = ['technical', 'narrative'] as const; -const DOWNLOAD_URL_TTL_SECONDS = 900; -const REPORT_ID_ROUTE = /^\/(\d+)$/; -const REPORT_DOWNLOAD_ROUTE = /^(?:\/reports)?\/(\d+)\/download$/; - -/** - * Best-effort removal of the generated file behind a deleted report. - * - * Deliberately never throws: the row is already gone by the time this runs, and - * the caller must not turn a successful delete into a 500 because S3 was - * unreachable or the role is missing `s3:DeleteObject`. A leftover object is - * recoverable; a row that cannot be deleted is not. - */ -async function deleteReportObject(objectUrl: string | null): Promise { - if (!objectUrl) return true; - const key = keyFromObjectUrl(objectUrl); - if (!key) return false; - // Read at call time rather than using the module-level BUCKET: the value is - // then observable to callers that set it after import, which is what the - // unit tests do. - const bucket = process.env.REPORTS_BUCKET_NAME ?? ''; - if (!bucket) { - console.error('REPORTS_BUCKET_NAME is not set; leaving report object', key); - return false; - } - try { - await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); - return true; - } catch (err) { - console.error('Failed to delete report object', key, err); - return false; - } -} - -async function requireAuth( - event: any -): Promise<{ user: NonNullable>['user']> } | { errorResponse: APIGatewayProxyResult }> { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return { errorResponse: json(401, { message: 'Authentication required' }) }; - } - return { user: authContext.user }; -} - -type FileType = typeof ALLOWED_EXTENSIONS[number]; -type ReportType = typeof REPORT_TYPES[number]; - -export const handler = async (event: any): Promise => { - try { - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /reports[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/reports(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } - - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // POST /reports/generate - if ((normalizedPath === '/reports/generate' || normalizedPath === '/generate') && method === 'POST') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - const body = event.body ? JSON.parse(event.body) as Record : {}; - - const projectId = body.project_id; - if (projectId === undefined || projectId === null) { - return json(400, { message: 'project_id is required' }); - } - if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { - return json(400, { message: 'project_id must be a positive integer' }); - } - - const fileType = (body.file_type ?? 'pdf') as FileType; - if (!ALLOWED_EXTENSIONS.includes(fileType)) { - return json(400, { message: `file_type must be one of: ${ALLOWED_EXTENSIONS.join(', ')}` }); - } - - const reportType = (body.report_type ?? 'technical') as ReportType; - if (!REPORT_TYPES.includes(reportType)) { - return json(400, { message: `report_type must be one of: ${REPORT_TYPES.join(', ')}` }); - } - - const reportData = await fetchReportData(projectId); - if (!reportData) { - return json(404, { message: 'Project not found' }); - } - - const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); - if (!hasAccess) { - return json(403, { message: 'You do not have access to generate reports for this project' }); - } - - let fileBuffer: Buffer; - try { - fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); - } catch (err) { - console.error('Report generation error:', err); - return json(500, { message: 'Failed to generate report' }); - } - - let objectUrl: string; - try { - objectUrl = await uploadToS3(fileBuffer, projectId, fileType); - } catch (err) { - console.error('S3 upload error:', err); - return json(500, { message: 'Failed to upload report' }); - } - - const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; - const record = await saveReportRecord(projectId, objectUrl, title, reportType); - - return json(201, { - ok: true, - report_id: record.report_id, - object_url: record.object_url, - report_type: record.report_type, - file_type: fileType, - }); - } - - // GET /reports - if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - const projectIdStr = queryParams.projectId as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - if (projectIdStr !== undefined) { - if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); - - return json(200, { - data: reports, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); - - return json(200, { data: reports }); - } - - // GET /reports/upload-url - if ((normalizedPath === '/reports/upload-url' || normalizedPath === '/upload-url') && method === 'GET') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const queryParams = event.queryStringParameters || {}; - const { fileName, projectId: projectIdStr } = queryParams; - - if (!fileName || typeof fileName !== 'string') { - return json(400, { message: 'fileName is required' }); - } - const safeFileName = fileName.replace(/^.*[\\/]/, '').replace(/[^A-Za-z0-9._-]/g, '_'); - if (!/[A-Za-z0-9]/.test(safeFileName)) { - return json(400, { message: 'Invalid fileName' }); - } - const ext = safeFileName.split('.').pop()?.toLowerCase() ?? ''; - if (!ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) { - return json(400, { message: 'Only PDF and DOCX files are supported' }); - } - if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - const projectId = parseInt(projectIdStr, 10); - - const projectExists = await db.selectFrom('branch.projects') - .where('project_id', '=', projectId) - .select('project_id') - .executeTakeFirst(); - if (!projectExists) return json(404, { message: 'Project not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to upload reports for this project' }); - } - - const key = `${reportKeyPrefix(projectId)}${Date.now()}-${safeFileName}`; - const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - ContentType: MIME_TYPES[ext], - }), { expiresIn: 3600 }); - - return json(200, { uploadUrl, objectUrl: objectUrlFor(key) }); - } - - // POST /reports - if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - let body: Record; - try { - body = event.body ? JSON.parse(event.body) : {}; - } catch { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { title, projectId, objectUrl, reportType } = body; - - if (!title || typeof title !== 'string' || title.trim().length === 0) { - return json(400, { message: 'title is required' }); - } - if (!projectId || typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - if (!objectUrl || typeof objectUrl !== 'string') { - return json(400, { message: 'objectUrl is required' }); - } - const postedKey = keyFromObjectUrl(objectUrl); - if (!postedKey) { - return json(400, { message: 'objectUrl must point at the reports bucket' }); - } - const resolvedReportType: ReportType = (reportType && REPORT_TYPES.includes(reportType as ReportType)) ? reportType as ReportType : 'technical'; - - const projectExists = await db.selectFrom('branch.projects') - .where('project_id', '=', projectId as number) - .select('project_id') - .executeTakeFirst(); - if (!projectExists) return json(404, { message: 'Project not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, projectId as number, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to upload reports for this project' }); - } - - // Checked after authorization: the key must sit under this project's prefix, - // or a caller with access to one project could register another project's - // object and then read it back through GET /reports/{id}/download. - if (!postedKey.startsWith(reportKeyPrefix(projectId))) { - return json(400, { message: "objectUrl must point at this project's prefix in the reports bucket" }); - } - - const report = await db - .insertInto('branch.reports') - .values({ project_id: projectId, title: (title as string).trim(), object_url: objectUrl as string, report_type: resolvedReportType }) - .returningAll() - .executeTakeFirst(); - - return json(201, report); - } - - // GET /reports/{id}/download - const downloadMatch = method === 'GET' ? normalizedPath.match(REPORT_DOWNLOAD_ROUTE) : null; - if (downloadMatch) { - const id = downloadMatch[1]; - - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); - if (!report) return json(404, { message: 'Report not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to this report' }); - } - - const key = keyFromObjectUrl(report.object_url); - if (!key || !key.startsWith(reportKeyPrefix(report.project_id))) { - return json(409, { message: 'Report is not stored in the reports bucket' }); - } - - const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({ - Bucket: BUCKET, - Key: key, - }), { expiresIn: DOWNLOAD_URL_TTL_SECONDS }); - - return json(200, { downloadUrl, expiresIn: DOWNLOAD_URL_TTL_SECONDS }); - } - - // GET /reports/{id} - const getIdMatch = method === 'GET' ? normalizedPath.match(REPORT_ID_ROUTE) : null; - if (getIdMatch) { - const id = getIdMatch[1]; - - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); - if (!report) return json(404, { message: 'Report not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to this report' }); - } - - return json(200, { ok: true, route: 'GET /reports/{id}', pathParams: { id }, body: report }); - } - - // DELETE /reports/{id} - const deleteIdMatch = method === 'DELETE' ? normalizedPath.match(REPORT_ID_ROUTE) : null; - if (deleteIdMatch) { - const id = deleteIdMatch[1]; - - const authResult = await requireAuth(event); - if ('errorResponse' in authResult) return authResult.errorResponse; - const { user } = authResult; - - const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); - if (!report) return json(404, { message: 'Report not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to delete this report' }); - } - - const deleted = await db.deleteFrom('branch.reports').where('report_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'Report not found' }); - } - - // After the row, never before: if the file went first and this delete - // failed, the report would be gone with a row still pointing at it. - const fileDeleted = await deleteReportObject(report.object_url); - - return json(200, { ok: true, route: 'DELETE /reports/{id}', pathParams: { id }, fileDeleted }); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +export const handler = (event: any) => dispatch(event, { prefix: 'reports', routes }); diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index fc74f315..dfba4600 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", @@ -52,6 +53,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1458,6 +1475,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index f1262651..f53f7ec3 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -30,6 +30,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", diff --git a/apps/backend/lambdas/reports/routes.ts b/apps/backend/lambdas/reports/routes.ts new file mode 100644 index 00000000..6b5fdee4 --- /dev/null +++ b/apps/backend/lambdas/reports/routes.ts @@ -0,0 +1,22 @@ +import type { Route } from '@branch/lambda-http'; +import { + generateReport, + listReports, + getUploadUrl, + createReport, + downloadReport, + getReport, + deleteReport, +} from './controllers/reports'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'POST', pattern: '/reports/generate', handler: generateReport }, + { method: 'GET', pattern: '/reports', handler: listReports }, + { method: 'GET', pattern: '/reports/upload-url', handler: getUploadUrl }, + { method: 'POST', pattern: '/reports', handler: createReport }, + { method: 'GET', pattern: '/reports/:id/download', handler: downloadReport }, + { method: 'GET', pattern: '/reports/:id', handler: getReport }, + { method: 'DELETE', pattern: '/reports/:id', handler: deleteReport }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index 730669d2..6896f37a 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -417,6 +417,39 @@ describe('GET /reports/upload-url unit tests', () => { }); }); +describe('Route precedence', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + }); + + // /reports/upload-url and /reports/:id both have two path segments, so + // upload-url must be registered before :id or it gets swallowed as an id lookup. + test('GET /reports/upload-url reaches getUploadUrl, not the /reports/:id controller', async () => { + const res = await handler({ + rawPath: '/reports/upload-url', + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + }); + // getUploadUrl-specific validation, not the 404 a numeric-id check on "upload-url" would give. + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('fileName is required'); + }); + + test('POST /reports/generate reaches generateReport, not the generic POST /reports controller', async () => { + const res = await handler({ + rawPath: '/reports/generate', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + body: JSON.stringify({}), + }); + // generateReport-specific validation, not createReport's 'title is required'. + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('project_id is required'); + }); +}); + describe('POST /reports unit tests', () => { const fakeObjectUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/1/123-report.pdf'; diff --git a/apps/backend/lambdas/reports/tsconfig.json b/apps/backend/lambdas/reports/tsconfig.json index d35b2baa..dc8dacce 100644 --- a/apps/backend/lambdas/reports/tsconfig.json +++ b/apps/backend/lambdas/reports/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } diff --git a/apps/backend/lambdas/tools/lambda-cli.js b/apps/backend/lambdas/tools/lambda-cli.js index 51c2013e..460e5435 100644 --- a/apps/backend/lambdas/tools/lambda-cli.js +++ b/apps/backend/lambdas/tools/lambda-cli.js @@ -786,10 +786,36 @@ function normalizePathForComparison(path) { } // Extract routes from handler.ts +// Reads the route table a converted lambda declares in routes.ts. Returns null +// when there is no table, so callers fall back to parsing handler.ts. +function extractRoutesFromRoutesTable(handlerPath) { + const routesPath = path.join(path.dirname(handlerPath), 'routes.ts'); + if (!fs.existsSync(routesPath)) return null; + + const source = fs.readFileSync(routesPath, 'utf8'); + const routes = []; + const entryRegex = /method:\s*['"]([A-Za-z]+)['"]\s*,\s*pattern:\s*['"]([^'"]+)['"]/g; + + let match; + while ((match = entryRegex.exec(source)) !== null) { + // `:param` is the router's spelling; READMEs and the OpenAPI specs use {param}. + const routePath = match[2].replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + routes.push({ method: match[1].toUpperCase(), path: routePath }); + } + + return routes; +} + function extractRoutesFromHandler(handlerPath) { + // Converted lambdas keep their routes in routes.ts; the if-chain parsing below + // finds nothing in their four-line handler.ts and would silently report zero + // routes, which is how the README workflow came to delete them. + const tableRoutes = extractRoutesFromRoutesTable(handlerPath); + if (tableRoutes) return tableRoutes; + const source = fs.readFileSync(handlerPath, 'utf8'); const routes = []; - + // Find the routes section between ROUTES-START and ROUTES-END const startMarker = '// >>> ROUTES-START'; const endMarker = '// <<< ROUTES-END'; @@ -1160,9 +1186,15 @@ function collectRoutes(handlerPath, openapiPath) { } } - const routes = [{ method: 'GET', path: '/health', description: 'Health check' }]; + // A converted lambda serves health centrally under its prefix, and its spec + // says so; an unconverted one still declares a bare /health. Follow whichever + // applies, and skip both spellings below so only one row is emitted. + const service = path.basename(path.dirname(handlerPath)); + const converted = fs.existsSync(path.join(path.dirname(handlerPath), 'routes.ts')); + const healthPath = converted ? `/${service}/health` : '/health'; + const routes = [{ method: 'GET', path: healthPath, description: 'Health check' }]; for (const route of routeMap.values()) { - if (route.method === 'GET' && route.path === '/health') continue; + if (route.method === 'GET' && (route.path === '/health' || route.path === healthPath)) continue; routes.push({ method: route.method, path: route.path, description: '' }); } return routes; diff --git a/apps/backend/lambdas/users/Dockerfile b/apps/backend/lambdas/users/Dockerfile index 0560151b..e4bc86f6 100644 --- a/apps/backend/lambdas/users/Dockerfile +++ b/apps/backend/lambdas/users/Dockerfile @@ -9,6 +9,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/users/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/users/README.md b/apps/backend/lambdas/users/README.md index 80d91408..6e513a96 100644 --- a/apps/backend/lambdas/users/README.md +++ b/apps/backend/lambdas/users/README.md @@ -8,10 +8,10 @@ Lambda for managing users. | Method | Path | Description | |--------|------|-------------| -| GET | /health | Health check | +| GET | /users/health | Health check | | GET | /users | | -| GET | /{userId} | | -| PATCH | /{userId} | | +| GET | /users/{userId} | | +| PATCH | /users/{userId} | | | DELETE | /users/{userId} | | | POST | /users | | diff --git a/apps/backend/lambdas/users/controllers/users.ts b/apps/backend/lambdas/users/controllers/users.ts new file mode 100644 index 00000000..4da09c8e --- /dev/null +++ b/apps/backend/lambdas/users/controllers/users.ts @@ -0,0 +1,278 @@ +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminDeleteUserCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { json, createAuthGuard, type RouteHandler } from '@branch/lambda-http'; +import db from '../db'; +import { authenticateRequest } from '../auth'; +import { UserValidationUtils } from '../validation-utils'; + +const cognitoClient = new CognitoIdentityProviderClient({ + region: process.env.AWS_REGION || 'us-east-2', +}); + +const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; + +const guard = createAuthGuard(authenticateRequest); + +export const listUsers: RouteHandler = async ({ event }) => { + const auth = await guard(event, 'ADMIN'); + if (auth.response) return auth.response; + + const queryParams = event.queryStringParameters || {}; + const page = queryParams.page ? parseInt(queryParams.page, 10) : null; + const limit = queryParams.limit ? parseInt(queryParams.limit, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.users') + .select(db.fn.count('user_id').as('count')) + .executeTakeFirst(); + + const totalUsers = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalUsers / limit); + + const users = await db + .selectFrom('branch.users') + .selectAll() + .orderBy('user_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + return json(200, { + users, + pagination: { + page, + limit, + totalUsers, + totalPages + } + }); + } + + const users = await db + .selectFrom('branch.users') + .selectAll() + .execute(); + + return json(200, { users }); +}; + +export const getUser: RouteHandler = async ({ event, params }) => { + const userId = params.userId; + const auth = await guard(event, 'ADMIN_OR_SELF', userId); + if (auth.response) return auth.response; + + if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); + + const user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + return json(200, { + ok: true, + route: 'GET /users/{userId}', + pathParams: { userId }, + body: { + userId: user.user_id, + email: user.email, + name: user.name, + isAdmin: user.is_admin, + profile_image: user.profile_image, + } + }); +}; + +export const patchUser: RouteHandler = async ({ event, params }) => { + const userId = params.userId; + const auth = await guard(event, 'ADMIN_OR_SELF', userId); + if (auth.response) return auth.response; + const authContext = auth.ctx; + + if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); + const body = event.body ? JSON.parse(event.body) as Record : {}; + + // make sure user exists + let user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + const updates: { name?: string; is_admin?: boolean; profile_image?: string } = {}; + + // email is the Cognito username and nothing here syncs it, so it is immutable + if (body.email !== undefined && body.email !== null && body.email !== '') { + return json(400, { message: 'email cannot be changed' }); + } + + const nameResult = UserValidationUtils.validateName(body.name); + if (!nameResult.isValid) return json(400, { message: nameResult.error }); + if (nameResult.value != null) updates.name = nameResult.value; + + const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); + if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); + if (isAdminResult.value != null) { + // is_admin is a privilege grant, not profile data. The ADMIN_OR_SELF + // check above intentionally lets a non-admin PATCH their own row, so + // without this gate any user could PATCH { isAdmin: true } to their own + // userId and self-promote. validateIsAdmin returns value: null when the + // field is absent, so ordinary self-service edits are unaffected. + if (!authContext.user?.isAdmin) { + return json(403, { message: 'Only an admin can change isAdmin' }); + } + updates.is_admin = isAdminResult.value; + } + + const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); + if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); + if (profileImageResult.value != null) updates.profile_image = profileImageResult.value; + + if (Object.keys(updates).length === 0) { + return json(400, { message: 'No valid fields provided to update' }); + } + + // update + await db.updateTable('branch.users') + .set(updates) + .where('user_id', '=', Number(userId)) + .execute(); + + // get updated user + let updatedUser = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); + + return json(200, { ok: true, route: 'PATCH /users/{userId}', pathParams: { userId }, body: { email: updatedUser!.email, name: updatedUser!.name, isAdmin: updatedUser!.is_admin, profileImage: updatedUser!.profile_image } }); +}; + +export const deleteUser: RouteHandler = async ({ event, params }) => { + const auth = await guard(event, 'ADMIN'); + if (auth.response) return auth.response; + + const userId = params.userId; + if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); + + const user = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).select('email').executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); + + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'User not found' }); + } + + // the Cognito user must go too, or the email can never be re-invited + let cognitoDeleted = true; + if (!USER_POOL_ID) { + console.error('COGNITO_USER_POOL_ID is not set; skipping Cognito delete for', user.email); + cognitoDeleted = false; + } else { + try { + await cognitoClient.send(new AdminDeleteUserCommand({ UserPoolId: USER_POOL_ID, Username: user.email })); + } catch (err: any) { + if (err?.name !== 'UserNotFoundException') { + console.error('Cognito delete error:', err); + cognitoDeleted = false; + } + } + } + + return json(200, { ok: true, route: 'DELETE /users/{userId}', pathParams: { userId }, cognitoDeleted }); +}; + +export const createUser: RouteHandler = async ({ event }) => { + const auth = await guard(event, 'ADMIN'); + if (auth.response) return auth.response; + + const body = event.body + ? (JSON.parse(event.body) as Record) + : {}; + + // email, name, and isAdmin are required on create + if (!body.email || !body.name || body.isAdmin === undefined || body.isAdmin === null) { + return json(400, { message: 'email, name, and isAdmin are required' }); + } + + // validate the type/format of each field + const emailResult = UserValidationUtils.validateEmail(body.email); + if (!emailResult.isValid) return json(400, { message: emailResult.error }); + + const nameResult = UserValidationUtils.validateName(body.name); + if (!nameResult.isValid) return json(400, { message: nameResult.error }); + + const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); + if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); + + const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); + if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); + + const email = emailResult.value as string; + const name = nameResult.value as string; + const isAdmin = isAdminResult.value as boolean; + const profile_image = profileImageResult.value ?? undefined; + + // Check if user with this email already exists in DB + const existingUser = await db + .selectFrom('branch.users') + .where('email', '=', email) + .selectAll() + .executeTakeFirst(); + + if (existingUser) { + return json(409, { message: 'User with this email already exists' }); + } + + // Create user in Cognito via AdminCreateUser — sends invite email with temp password + let cognitoSub: string; + try { + const cognitoResponse = await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email, + DesiredDeliveryMediums: ['EMAIL'], + UserAttributes: [ + { Name: 'email', Value: email }, + { Name: 'email_verified', Value: 'true' }, + { Name: 'name', Value: name }, + ], + })); + const sub = cognitoResponse.User?.Attributes?.find(a => a.Name === 'sub')?.Value; + if (!sub) throw new Error('No sub returned from AdminCreateUser'); + cognitoSub = sub; + } catch (err: any) { + console.error('Cognito AdminCreateUser error:', err); + if (err.name === 'UsernameExistsException') { + return json(409, { message: 'User with this email already exists' }); + } + return json(500, { message: 'Failed to create user in authentication service' }); + } + + // Insert into database with cognito_sub + try { + await db + .insertInto('branch.users') + .values({ cognito_sub: cognitoSub, email, name, is_admin: isAdmin, profile_image }) + .execute(); + } catch (err: any) { + console.error('Database insert error:', err); + // Rollback: delete Cognito user to keep systems in sync + try { + await cognitoClient.send(new AdminDeleteUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email, + })); + console.log('Rolled back Cognito user after database failure'); + } catch (rollbackErr) { + console.error('Failed to rollback Cognito user:', rollbackErr); + } + return json(500, { message: 'Failed to create user' }); + } + + return json(201, { + ok: true, + route: 'POST /users', + pathParams: {}, + body: { + email, + name, + isAdmin, + }, + }); +}; diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index 4f32be7a..b9f288d2 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,344 +1,4 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { - CognitoIdentityProviderClient, - AdminCreateUserCommand, - AdminDeleteUserCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import db from './db' -import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; -import { UserValidationUtils } from './validation-utils'; +import { dispatch } from '@branch/lambda-http'; +import { routes } from './routes'; -const cognitoClient = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION || 'us-east-2', -}); - -const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; - -function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { - const authCheck = checkAuthorization(authContext, level, resourceUserId); - if (!authCheck.allowed) { - return authContext.isAuthenticated - ? json(403, { message: authCheck.reason || 'Forbidden' }) - : json(401, { message: 'Authentication required' }); - } -} - - -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /users[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/users(?=\/|$)/, '') || '/'; - let normalizedPath = rawPath.replace(/\/$/, ''); - if (normalizedPath.length === 0) { - normalizedPath = '/'; - } - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight — must return 2xx before auth, or the browser blocks it. - if (method === 'OPTIONS') { - return json(200, {}); - } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - const authContext: AuthContext = await authenticateRequest(event); - - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - - // GET /users - if ((normalizedPath === '/users' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - // TODO: Add your business logic here - const queryParams = event.queryStringParameters || {}; - const page = queryParams.page ? parseInt(queryParams.page, 10) : null; - const limit = queryParams.limit ? parseInt(queryParams.limit, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.users') - .select(db.fn.count('user_id').as('count')) - .executeTakeFirst(); - - const totalUsers = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalUsers / limit); - - const users = await db - .selectFrom('branch.users') - .selectAll() - .orderBy('user_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - return json(200, { - users, - pagination: { - page, - limit, - totalUsers, - totalPages - } - }); - } - - const users = await db - .selectFrom('branch.users') - .selectAll() - .execute(); - - return json(200, { users }); - } - - // GET /{userId} - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'GET') { - const userId = normalizedPath.split('/')[1]; - const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); - if (authError) return authError; - - if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); - - const user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - return json(200, { - ok: true, - route: 'GET /users/{userId}', - pathParams: { userId }, - body: { - userId: user.user_id, - email: user.email, - name: user.name, - isAdmin: user.is_admin, - profile_image: user.profile_image, - } - }); - } - - // PATCH /{userId} (dev server strips /users prefix) - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'PATCH') { - const userId = normalizedPath.split('/')[1]; - const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); - if (authError) return authError; - - if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // make sure user exists - let user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - const updates: { name?: string; is_admin?: boolean; profile_image?: string } = {}; - - // email is the Cognito username and nothing here syncs it, so it is immutable - if (body.email !== undefined && body.email !== null && body.email !== '') { - return json(400, { message: 'email cannot be changed' }); - } - - const nameResult = UserValidationUtils.validateName(body.name); - if (!nameResult.isValid) return json(400, { message: nameResult.error }); - if (nameResult.value != null) updates.name = nameResult.value; - - const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); - if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); - if (isAdminResult.value != null) { - // is_admin is a privilege grant, not profile data. The ADMIN_OR_SELF - // check above intentionally lets a non-admin PATCH their own row, so - // without this gate any user could PATCH { isAdmin: true } to their own - // userId and self-promote. validateIsAdmin returns value: null when the - // field is absent, so ordinary self-service edits are unaffected. - if (!authContext.user?.isAdmin) { - return json(403, { message: 'Only an admin can change isAdmin' }); - } - updates.is_admin = isAdminResult.value; - } - - const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); - if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); - if (profileImageResult.value != null) updates.profile_image = profileImageResult.value; - - if (Object.keys(updates).length === 0) { - return json(400, { message: 'No valid fields provided to update' }); - } - - // update - await db.updateTable('branch.users') - .set(updates) - .where('user_id', '=', Number(userId)) - .execute(); - - // get updated user - let updatedUser = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - - return json(200, { ok: true, route: 'PATCH /users/{userId}', pathParams: { userId }, body: { email: updatedUser!.email, name: updatedUser!.name, isAdmin: updatedUser!.is_admin, profileImage: updatedUser!.profile_image } }); - } - - // DELETE /users/{userId} - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const userId = normalizedPath.split('/')[1]; - if (!/^\d+$/.test(userId)) return json(400, { message: 'userId must be a positive integer' }); - - const user = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).select('email').executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); - - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'User not found' }); - } - - // the Cognito user must go too, or the email can never be re-invited - let cognitoDeleted = true; - if (!USER_POOL_ID) { - console.error('COGNITO_USER_POOL_ID is not set; skipping Cognito delete for', user.email); - cognitoDeleted = false; - } else { - try { - await cognitoClient.send(new AdminDeleteUserCommand({ UserPoolId: USER_POOL_ID, Username: user.email })); - } catch (err: any) { - if (err?.name !== 'UserNotFoundException') { - console.error('Cognito delete error:', err); - cognitoDeleted = false; - } - } - } - - return json(200, { ok: true, route: 'DELETE /users/{userId}', pathParams: { userId }, cognitoDeleted }); - } - - // POST /users - if ((normalizedPath === '/' || normalizedPath === '/users') && method === 'POST') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const body = event.body - ? (JSON.parse(event.body) as Record) - : {}; - - // email, name, and isAdmin are required on create - if (!body.email || !body.name || body.isAdmin === undefined || body.isAdmin === null) { - return json(400, { message: 'email, name, and isAdmin are required' }); - } - - // validate the type/format of each field - const emailResult = UserValidationUtils.validateEmail(body.email); - if (!emailResult.isValid) return json(400, { message: emailResult.error }); - - const nameResult = UserValidationUtils.validateName(body.name); - if (!nameResult.isValid) return json(400, { message: nameResult.error }); - - const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); - if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); - - const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); - if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); - - const email = emailResult.value as string; - const name = nameResult.value as string; - const isAdmin = isAdminResult.value as boolean; - const profile_image = profileImageResult.value ?? undefined; - - // Check if user with this email already exists in DB - const existingUser = await db - .selectFrom('branch.users') - .where('email', '=', email) - .selectAll() - .executeTakeFirst(); - - if (existingUser) { - return json(409, { message: 'User with this email already exists' }); - } - - // Create user in Cognito via AdminCreateUser — sends invite email with temp password - let cognitoSub: string; - try { - const cognitoResponse = await cognitoClient.send(new AdminCreateUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email, - DesiredDeliveryMediums: ['EMAIL'], - UserAttributes: [ - { Name: 'email', Value: email }, - { Name: 'email_verified', Value: 'true' }, - { Name: 'name', Value: name }, - ], - })); - const sub = cognitoResponse.User?.Attributes?.find(a => a.Name === 'sub')?.Value; - if (!sub) throw new Error('No sub returned from AdminCreateUser'); - cognitoSub = sub; - } catch (err: any) { - console.error('Cognito AdminCreateUser error:', err); - if (err.name === 'UsernameExistsException') { - return json(409, { message: 'User with this email already exists' }); - } - return json(500, { message: 'Failed to create user in authentication service' }); - } - - // Insert into database with cognito_sub - try { - await db - .insertInto('branch.users') - .values({ cognito_sub: cognitoSub, email, name, is_admin: isAdmin, profile_image }) - .execute(); - } catch (err: any) { - console.error('Database insert error:', err); - // Rollback: delete Cognito user to keep systems in sync - try { - await cognitoClient.send(new AdminDeleteUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email, - })); - console.log('Rolled back Cognito user after database failure'); - } catch (rollbackErr) { - console.error('Failed to rollback Cognito user:', rollbackErr); - } - return json(500, { message: 'Failed to create user' }); - } - - return json(201, { - ok: true, - route: 'POST /users', - pathParams: {}, - body: { - email, - name, - isAdmin, - }, - }); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} \ No newline at end of file +export const handler = (event: any) => dispatch(event, { prefix: 'users', routes }); diff --git a/apps/backend/lambdas/users/openapi.yaml b/apps/backend/lambdas/users/openapi.yaml index fa4d4413..741be558 100644 --- a/apps/backend/lambdas/users/openapi.yaml +++ b/apps/backend/lambdas/users/openapi.yaml @@ -3,9 +3,9 @@ info: title: users (Local) version: 1.0.0 servers: - - url: http://localhost:3000/users + - url: http://localhost:3000 paths: - /health: + /users/health: get: summary: Health check responses: @@ -58,7 +58,7 @@ paths: description: Bad Request '500': description: Internal Server Error - /{userId}: + /users/{userId}: get: summary: GET /users/{userId} parameters: diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index 378c2932..16898822 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" @@ -45,6 +46,22 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "dependencies": { + "@branch/lambda-auth": "file:../lambda-auth" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -822,6 +839,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 0e999510..be0613dc 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -26,6 +26,7 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" diff --git a/apps/backend/lambdas/users/routes.ts b/apps/backend/lambdas/users/routes.ts new file mode 100644 index 00000000..f0c3018f --- /dev/null +++ b/apps/backend/lambdas/users/routes.ts @@ -0,0 +1,12 @@ +import type { Route } from '@branch/lambda-http'; +import { listUsers, getUser, patchUser, deleteUser, createUser } from './controllers/users'; + +export const routes: Route[] = [ + // >>> ROUTES-START (do not remove this marker) + { method: 'GET', pattern: '/users', handler: listUsers }, + { method: 'GET', pattern: '/users/:userId', handler: getUser }, + { method: 'PATCH', pattern: '/users/:userId', handler: patchUser }, + { method: 'DELETE', pattern: '/users/:userId', handler: deleteUser }, + { method: 'POST', pattern: '/users', handler: createUser }, + // <<< ROUTES-END +]; diff --git a/apps/backend/lambdas/users/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index 077e5b3c..2d93c455 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { dispatch, json, type Route } from '@branch/lambda-http'; // Mock the database module BEFORE importing handler jest.mock('../db'); @@ -551,3 +552,30 @@ describe('PATCH /users/{userId} unit tests', () => { }); }); }); + +describe('route precedence', () => { + test('a literal segment route wins over a same-shaped :param route placed after it', async () => { + const literalHandler = jest.fn(async () => json(200, { matched: 'literal' })); + const paramHandler = jest.fn(async () => json(200, { matched: 'param' })); + + const routes: Route[] = [ + { method: 'GET', pattern: '/users/me', handler: literalHandler }, + { method: 'GET', pattern: '/users/:userId', handler: paramHandler }, + ]; + + const literalRes = await dispatch( + { rawPath: '/users/me', requestContext: { http: { method: 'GET' } } }, + { prefix: 'users', routes }, + ); + expect(JSON.parse(literalRes.body)).toEqual({ matched: 'literal' }); + expect(literalHandler).toHaveBeenCalledTimes(1); + expect(paramHandler).not.toHaveBeenCalled(); + + const paramRes = await dispatch( + { rawPath: '/users/42', requestContext: { http: { method: 'GET' } } }, + { prefix: 'users', routes }, + ); + expect(JSON.parse(paramRes.body)).toEqual({ matched: 'param' }); + expect(paramHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/lambdas/users/tsconfig.json b/apps/backend/lambdas/users/tsconfig.json index d35b2baa..dc8dacce 100644 --- a/apps/backend/lambdas/users/tsconfig.json +++ b/apps/backend/lambdas/users/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "controllers/**/*.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] }