diff --git a/.gitignore b/.gitignore index 7dcf7d01..5c090aed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist tmp /out-tsc +lambda.zip # dependencies node_modules diff --git a/AGENTS.md b/AGENTS.md index de053b68..b6211c17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi Two `file:`-linked packages dedupe code across lambdas: -- **`@branch/types`** (`shared/types/`) — types only, no runtime. Exports DB row types (`DB`, `BranchUsers`, ...) + auth DTOs (`AuthContext`, `AuthenticatedUser`, `AccessLevel`, `AuthorizationCheck`). `db-types.d.ts` is **generated** from `apps/backend/db/migrations/**` by the `Schema Change Checks` workflow (or locally by `make types`) — never hand-edit it. +- **`@branch/types`** (`shared/types/`) — types only, no runtime. Exports DB row types (`DB`, `BranchUsers`, ...) + auth DTOs (`AuthContext`, `AuthenticatedUser`, `AccessLevel`, `AuthorizationCheck`). It is the **single declaration** of those DTOs: `@branch/lambda-auth` depends on this package and re-exports them, so never add a second copy anywhere. `db-types.d.ts` is **generated** from `apps/backend/db/migrations/**` by the `Schema Change Checks` workflow (or locally by `make types`) — never hand-edit it. - **`@branch/lambda-auth`** (`shared/lambda-auth/`) — runtime auth: `authenticateRequest(db, event)`, `extractToken(event)`, `checkAuthorization(ctx, level, resourceUserId?)`. Lambdas wrap it in their local `auth.ts`. ## Root commands diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index a0baa687..8d94f2d0 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -50,8 +50,8 @@ http://localhost:3000//health ## Shared packages Both linked via `file:` deps in each lambda's `package.json`: -- `@branch/types` (`../../../../shared/types`) — devDependency, types only. -- `@branch/lambda-auth` (`../../../../shared/lambda-auth`) — dependency, runtime auth. Build it (`npm run build` in `shared/lambda-auth`) when its source changes; lambdas consume `dist/`. +- `@branch/types` (`../../../../shared/types`) — devDependency, types only. Must stay a dependency-free leaf: `@branch/lambda-auth` depends on it. +- `@branch/lambda-auth` (`../../../../shared/lambda-auth`) — dependency, runtime auth. Build it (`npm run build` in `shared/lambda-auth`) when its source changes; lambdas consume `dist/`. It depends on `@branch/types` and re-exports the auth DTOs from there, so those types have exactly one declaration; changing `shared/lambda-auth/package.json` deps invalidates every lambda's `package-lock.json`, so regenerate all six. ## Deploy diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 8fa37d62..5ede5f7e 100644 --- a/apps/backend/lambdas/auth/handler.ts +++ b/apps/backend/lambdas/auth/handler.ts @@ -733,17 +733,21 @@ async function handleRegister(event: any): Promise { ); const sub = cognitoUser.UserAttributes?.find((a) => a.Name === 'sub')?.Value; if (sub && cognitoUser.UserStatus === 'CONFIRMED') { - await db + const linkResult = await db .updateTable('branch.users') .set({ cognito_sub: sub }) .where('user_id', '=', claimingUserId) .where('cognito_sub', 'is', null) - .execute(); - return json(200, { - message: 'Existing account linked', - claimed: true, - email: email.toLowerCase(), - }); + .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); @@ -764,6 +768,20 @@ async function handleRegister(event: any): Promise { 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 @@ -773,27 +791,28 @@ async function handleRegister(event: any): Promise { // 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. - await db + const claimResult = await db .updateTable('branch.users') .set({ cognito_sub: cognitoUserSub, name: name.trim() }) .where('user_id', '=', claimingUserId) .where('cognito_sub', 'is', null) - .execute(); + .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 - 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); - } + await rollbackCognitoUser(); return json(500, { message: 'Failed to create user account' }); } diff --git a/apps/backend/lambdas/auth/package-lock.json b/apps/backend/lambdas/auth/package-lock.json index 51c08c83..469fa6c3 100644 --- a/apps/backend/lambdas/auth/package-lock.json +++ b/apps/backend/lambdas/auth/package-lock.json @@ -35,6 +35,7 @@ "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { diff --git a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts index 357f1075..a45445dd 100644 --- a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts +++ b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts @@ -23,6 +23,7 @@ jest.mock('../auth', () => ({ const mockExecuteTakeFirst = jest.fn(); const mockExecute = jest.fn(); +const mockUpdateResult = jest.fn(); const mockSet = jest.fn(); const mockValues = jest.fn(); @@ -40,6 +41,7 @@ jest.mock('../db', () => { }, where: () => updateChain, execute: (...a: unknown[]) => mockExecute(...a), + executeTakeFirst: (...a: unknown[]) => mockUpdateResult(...a), }; const insertChain: any = { values: (...a: unknown[]) => { @@ -84,6 +86,7 @@ const TOKENS = { beforeEach(() => { jest.clearAllMocks(); + mockUpdateResult.mockResolvedValue({ numUpdatedRows: 1n }); jest.spyOn(console, 'error').mockImplementation(() => undefined); jest.spyOn(console, 'warn').mockImplementation(() => undefined); jest.spyOn(console, 'log').mockImplementation(() => undefined); @@ -563,7 +566,7 @@ describe('POST /register — claim-on-register', () => { mockSend .mockResolvedValueOnce({ UserSub: 'new-sub' }) // SignUp .mockResolvedValueOnce({}); // AdminDeleteUser - mockExecute.mockRejectedValue(new Error('db down')); + mockUpdateResult.mockRejectedValue(new Error('db down')); const res = await handler(event('/register', 'POST', validBody)); diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index f1e5fed8..55bab5de 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -1,7 +1,7 @@ import { APIGatewayProxyResult } from 'aws-lambda'; import db from './db'; import { authenticateRequest } from './auth'; -import { DonorValidationUtils, DonationValidationUtils } from './validation-utils'; +import { DonorValidationUtils } from './validation-utils'; export const handler = async (event: any): Promise => { try { @@ -144,48 +144,79 @@ export const handler = async (event: any): Promise => { if (donor_id === undefined || project_id === undefined || amount === undefined) { return json(400, { message: 'donor_id, project_id, and amount are required' }); } - if (!Number.isInteger(donor_id) || (donor_id as number) < 1) { + // 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(project_id) || (project_id as number) < 1) { + if (!Number.isInteger(projectId) || projectId < 1) { return json(400, { message: 'project_id must be a positive integer' }); } - if (typeof amount !== 'number' || amount <= 0 || !isFinite(amount)) { + 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', '=', project_id as number) - .where('user_id', '=', userId) + 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 (!membership) { - return json(403, { message: 'You must be a member' }); + 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: donor_id as number, - project_id: project_id as number, - amount: amount as number, - }) - .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' }); + const donation = await db + .insertInto('branch.project_donations') + .values({ + donor_id: donorId, + project_id: projectId, + amount: donationAmount, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return json(201, { data: donation }); + } catch (err: any) { + if (err?.code === '23505') { + return json(409, { message: 'A donation from this donor to this project already exists' }); + } + if (err?.code === '23503') { + return json(404, { message: 'Donor or project not found' }); + } + throw err; } - throw err; - } } // POST /donors diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index 2d717abf..66b5b492 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -31,10 +31,15 @@ "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index 3f0a570a..baf4cd21 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -34,10 +34,15 @@ "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index ab5d0d9a..a5d5231a 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -143,6 +143,7 @@ export const handler = async (event: any): Promise => { // handles both /projects/1/members and /1/members const id = parts.length === 3 ? parts[1] : parts[0]; if (!id) return json(400, { message: 'id is required' }); + if (!/^\d+$/.test(id)) return json(400, { message: 'Project id must be a valid number' }); if (!(await canAccessProject(user.userId!, Number(id)))) { return json(403, { message: 'You do not have access to this project' }); } @@ -155,7 +156,7 @@ export const handler = async (event: any): Promise => { 'u.email', 'pm.role' ]) - .where('pm.project_id', '=', id) + .where('pm.project_id', '=', Number(id)) .execute(); return json(200, { ok: true, route: 'GET /projects/{id}/members', pathParams: { id }, body: { @@ -213,7 +214,7 @@ export const handler = async (event: any): Promise => { "branch.donors as bd", "bd.donor_id", "bpd.donor_id" - ).selectAll().execute(); + ).select(['bd.donor_id', 'bd.organization', 'bd.contact_name', 'bd.contact_email', 'bpd.donation_id', 'bpd.amount', 'bpd.donated_at']).execute(); return json(200, { donors }); } @@ -221,6 +222,7 @@ export const handler = async (event: any): Promise => { if (rawPath.startsWith('/') && rawPath.split('/').length === 2 && method === 'GET') { const id = rawPath.split('/')[1]; if (!id) return json(400, { message: 'id is required' }); + if (!/^\d+$/.test(id)) return json(400, { message: 'Project id must be a valid number' }); if (!(await canAccessProject(user.userId!, Number(id)))) { return json(403, { message: 'You do not have access to this project' }); } @@ -234,6 +236,7 @@ export const handler = async (event: any): Promise => { if (rawPath.startsWith('/') && rawPath.split('/').length === 2 && method === 'PUT') { const id = rawPath.split('/')[1]; if (!id) return json(400, { message: 'id is required' }); + if (!/^\d+$/.test(id)) return json(400, { message: 'Project id must be a valid number' }); if (!(await canEditProject(user.userId!, Number(id)))) { return json(403, { message: 'You do not have access to edit this project' }); } @@ -347,6 +350,7 @@ export const handler = async (event: any): Promise => { id = pathParts[0]; } if (!id) return json(400, { message: 'id is required' }); + if (!/^\d+$/.test(id)) return json(400, { message: 'Project id must be a valid number' }); if (!(await canAccessProject(user.userId!, Number(id)))) { return json(403, { message: 'You do not have access to this project' }); @@ -356,7 +360,7 @@ export const handler = async (event: any): Promise => { const project = await db .selectFrom('branch.projects') - .where('project_id', '=', parseInt(id)) + .where('project_id', '=', Number(id)) .selectAll() .executeTakeFirst(); @@ -367,7 +371,7 @@ export const handler = async (event: any): Promise => { const expenditures = await db .selectFrom('branch.expenditures') - .where('project_id', '=', parseInt(id)) + .where('project_id', '=', Number(id)) .selectAll() .orderBy('spent_on', 'desc') .execute(); diff --git a/apps/backend/lambdas/projects/package-lock.json b/apps/backend/lambdas/projects/package-lock.json index 362c36fa..949510af 100644 --- a/apps/backend/lambdas/projects/package-lock.json +++ b/apps/backend/lambdas/projects/package-lock.json @@ -32,14 +32,21 @@ "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, "../../../../shared/types": { + "name": "@branch/types", + "version": "1.0.0", "dev": true }, "node_modules/@babel/code-frame": { @@ -1925,9 +1932,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1942,9 +1946,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1959,9 +1960,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1976,9 +1974,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1993,9 +1988,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2010,9 +2002,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2027,9 +2016,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2044,9 +2030,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2061,9 +2044,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2078,9 +2058,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/apps/backend/lambdas/projects/test/example.test.ts b/apps/backend/lambdas/projects/test/example.test.ts index 37223067..2e6d56fb 100644 --- a/apps/backend/lambdas/projects/test/example.test.ts +++ b/apps/backend/lambdas/projects/test/example.test.ts @@ -86,13 +86,6 @@ test("get projects yes donors test 🌞", async () => { expect(Array.isArray(body.donors)).toBe(true); if (body.donors.length > 0) { const donor = body.donors[0]; - expect(donor.project_id).toBeDefined(); - expect(donor.name).toBeDefined(); - expect(donor.total_budget).toBeDefined(); - expect(donor.start_date).toBeDefined(); - expect(donor.end_date).toBeDefined(); - expect(donor.currency).toBeDefined(); - expect(donor.created_at).toBeDefined(); expect(donor.donation_id).toBeDefined(); expect(donor.donor_id).toBeDefined(); expect(donor.amount).toBeDefined(); diff --git a/apps/backend/lambdas/projects/test/projects.unit.test.ts b/apps/backend/lambdas/projects/test/projects.unit.test.ts index 24fc0857..e387e172 100644 --- a/apps/backend/lambdas/projects/test/projects.unit.test.ts +++ b/apps/backend/lambdas/projects/test/projects.unit.test.ts @@ -192,9 +192,9 @@ test('404: project not found', async () => { expect(json.message).toBe('Project not found'); }); -test('500: invalid id causes error', async () => { +test('400: invalid id is rejected', async () => { const res = await handler(getExpendituresEvent('invalid')); - expect(res.statusCode).toBe(500); + expect(res.statusCode).toBe(400); const json = JSON.parse(res.body); - expect(json.message).toContain('Failed to fetch expenditures'); + expect(json.message).toBe('Project id must be a valid number'); }); diff --git a/apps/backend/lambdas/reports/README.md b/apps/backend/lambdas/reports/README.md index 321042fa..f02cd638 100644 --- a/apps/backend/lambdas/reports/README.md +++ b/apps/backend/lambdas/reports/README.md @@ -13,6 +13,7 @@ TODO: Add a description of the reports lambda. | GET | /reports | | | GET | /reports/upload-url | | | POST | /reports | | +| GET | /reports/{id}/download | | | GET | /reports/{id} | | | DELETE | /reports/{id} | | diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 2ef87932..c9b1226f 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -1,5 +1,5 @@ import { APIGatewayProxyResult } from 'aws-lambda'; -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import db from './db'; import { authenticateRequest } from './auth'; @@ -10,18 +10,23 @@ import { 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 REGION = process.env.AWS_REGION ?? 'us-east-2'; const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const; const MIME_TYPES: Record = { pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }; +const REPORT_TYPES = ['technical', 'narrative'] as const; +const DOWNLOAD_URL_TTL_SECONDS = 900; const REPORT_ID_ROUTE = /^\/(\d+)$/; +const REPORT_DOWNLOAD_ROUTE = /^(?:\/reports)?\/(\d+)\/download$/; async function requireAuth( event: any @@ -34,6 +39,7 @@ async function requireAuth( } type FileType = typeof ALLOWED_EXTENSIONS[number]; +type ReportType = typeof REPORT_TYPES[number]; export const handler = async (event: any): Promise => { try { @@ -76,6 +82,11 @@ export const handler = async (event: any): Promise => { 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' }); @@ -103,13 +114,14 @@ export const handler = async (event: any): Promise => { } const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; - const record = await saveReportRecord(projectId, objectUrl, title); + 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, }); } @@ -184,7 +196,11 @@ export const handler = async (event: any): Promise => { if (!fileName || typeof fileName !== 'string') { return json(400, { message: 'fileName is required' }); } - const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; + 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' }); } @@ -204,16 +220,14 @@ export const handler = async (event: any): Promise => { return json(403, { message: 'You do not have access to upload reports for this project' }); } - const key = `reports/${projectId}/${Date.now()}-${fileName}`; + const key = `${reportKeyPrefix(projectId)}${Date.now()}-${safeFileName}`; const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: MIME_TYPES[ext], }), { expiresIn: 3600 }); - const objectUrl = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`; - - return json(200, { uploadUrl, objectUrl }); + return json(200, { uploadUrl, objectUrl: objectUrlFor(key) }); } // POST /reports @@ -240,8 +254,10 @@ export const handler = async (event: any): Promise => { if (!objectUrl || typeof objectUrl !== 'string') { return json(400, { message: 'objectUrl is required' }); } - const REPORT_TYPES = ['technical', 'narrative'] as const; - type ReportType = typeof REPORT_TYPES[number]; + 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') @@ -255,6 +271,13 @@ export const handler = async (event: any): Promise => { 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 }) @@ -264,6 +287,36 @@ export const handler = async (event: any): Promise => { 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) { diff --git a/apps/backend/lambdas/reports/openapi.yaml b/apps/backend/lambdas/reports/openapi.yaml index 9d4a3687..84ca41de 100644 --- a/apps/backend/lambdas/reports/openapi.yaml +++ b/apps/backend/lambdas/reports/openapi.yaml @@ -214,6 +214,45 @@ paths: '500': description: Internal server error + /reports/{id}/download: + get: + summary: Get a pre-signed S3 URL for downloading a report file + description: > + Returns a short-lived pre-signed GET URL for the report's stored S3 + object. The reports bucket is private, so the stored object_url is not + directly fetchable. Requires the caller to be a member of the report's + project or a global admin. + parameters: + - in: path + name: id + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Pre-signed download URL + content: + application/json: + schema: + type: object + properties: + downloadUrl: + type: string + description: Pre-signed S3 GET URL + expiresIn: + type: integer + description: Seconds until the URL expires + example: 900 + '401': + description: Authentication required + '403': + description: No access to this report + '404': + description: Report not found + '409': + description: Report is not stored in the reports bucket + /reports/{id}: get: summary: GET /reports/{id} diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index 6cd0c4a6..fc74f315 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -40,10 +40,15 @@ "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, diff --git a/apps/backend/lambdas/reports/report-service.ts b/apps/backend/lambdas/reports/report-service.ts index 02434bd0..b43c334d 100644 --- a/apps/backend/lambdas/reports/report-service.ts +++ b/apps/backend/lambdas/reports/report-service.ts @@ -32,6 +32,41 @@ function getBucketName(): string { return bucket; } +// Every report object lives under its project's prefix. Callers validate stored +// URLs against this, so a report row cannot reference another project's object. +export function reportKeyPrefix(projectId: number): string { + return `reports/${projectId}/`; +} + +export function objectUrlFor(key: string): string { + const region = process.env.AWS_REGION ?? 'us-east-2'; + return `https://${getBucketName()}.s3.${region}.amazonaws.com/${key}`; +} + +export function keyFromObjectUrl(objectUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(objectUrl); + } catch { + return null; + } + if (parsed.protocol !== 'https:') return null; + + const bucket = getBucketName(); + const region = process.env.AWS_REGION ?? 'us-east-2'; + // the region-less host is still accepted so rows written before objectUrlFor stay downloadable + const validHosts = [`${bucket}.s3.${region}.amazonaws.com`, `${bucket}.s3.amazonaws.com`]; + if (!validHosts.includes(parsed.host)) return null; + + let key: string; + try { + key = decodeURIComponent(parsed.pathname.replace(/^\//, '')); + } catch { + return null; + } + return key || null; +} + // The deploy bundle (esbuild) ships the Roboto TTFs under /fonts/Roboto // (see package.json "package"). In local dev (ts-node, unbundled) they live in // node_modules/pdfmake. pdfmake reads these files at render time, so they must @@ -492,7 +527,7 @@ export async function uploadToS3(fileBuffer: Buffer, projectId: number, fileType const bucketName = getBucketName(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const key = `reports/${projectId}/${timestamp}.${fileType}`; + const key = `${reportKeyPrefix(projectId)}${timestamp}.${fileType}`; const contentType = fileType === 'docx' ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' : 'application/pdf'; @@ -506,7 +541,7 @@ export async function uploadToS3(fileBuffer: Buffer, projectId: number, fileType }), ); - return `https://${bucketName}.s3.amazonaws.com/${key}`; + return objectUrlFor(key); } export async function saveReportRecord( diff --git a/apps/backend/lambdas/reports/test/reports.e2e.test.ts b/apps/backend/lambdas/reports/test/reports.e2e.test.ts index 7b931e34..fa78ba25 100644 --- a/apps/backend/lambdas/reports/test/reports.e2e.test.ts +++ b/apps/backend/lambdas/reports/test/reports.e2e.test.ts @@ -18,6 +18,9 @@ import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; +// objectUrlFor/keyFromObjectUrl require a bucket name; 'bucket' matches fakeObjectUrl below +process.env.REPORTS_BUCKET_NAME = 'bucket'; + const pool = new Pool({ host: 'localhost', port: Number(5432), @@ -287,7 +290,8 @@ describe('Reports e2e tests', () => { }); test('201: created report appears in subsequent GET /reports', async () => { - await handler(postEvent({ title: 'Verify Report', projectId: 2, objectUrl: fakeObjectUrl })); + const project2ObjectUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/2/123-report.pdf'; + await handler(postEvent({ title: 'Verify Report', projectId: 2, objectUrl: project2ObjectUrl })); const getRes = await handler(getEvent()); const getBody = JSON.parse(getRes.body); expect(getBody.data.some((r: any) => r.title === 'Verify Report')).toBe(true); diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index 218c2ed2..c080a652 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -18,6 +18,12 @@ jest.mock('../report-service', () => ({ generateDocx: jest.fn(), uploadToS3: jest.fn(), saveReportRecord: jest.fn(), + reportKeyPrefix: jest.fn((projectId: number) => `reports/${projectId}/`), + objectUrlFor: jest.fn((key: string) => `https://bucket.s3.us-east-2.amazonaws.com/${key}`), + keyFromObjectUrl: jest.fn((objectUrl: string) => { + const prefix = 'https://bucket.s3.us-east-2.amazonaws.com/'; + return objectUrl.startsWith(prefix) ? objectUrl.slice(prefix.length) : null; + }), })); import { handler } from '../handler'; @@ -470,6 +476,13 @@ describe('POST /reports unit tests', () => { expect(JSON.parse(res.body).message).toBe('Invalid JSON in request body'); }); + test("400: objectUrl under another project's prefix is rejected", async () => { + const otherProjectUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/2/123-report.pdf'; + const res = await handler(postEvent({ title: 'T', projectId: 1, objectUrl: otherProjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain("this project's prefix"); + }); + test('400: missing title returns 400', async () => { const res = await handler(postEvent({ projectId: 1, objectUrl: fakeObjectUrl })); expect(res.statusCode).toBe(400); diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index 1e5ff6c9..ef49f6e0 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,8 +1,18 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { + CognitoIdentityProviderClient, + AdminDeleteUserCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import db from './db' import { authenticateRequest, checkAuthorization, AuthContext } 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 || ''; + function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { const authCheck = checkAuthorization(authContext, level, resourceUserId); if (!authCheck.allowed) { @@ -28,8 +38,6 @@ export const handler = async (event: any): Promise => { } const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - console.log('DEBUG - rawPath:', rawPath, 'normalizedPath:', normalizedPath, 'method:', method); - // CORS preflight — must return 2xx before auth, or the browser blocks it. if (method === 'OPTIONS') { return json(200, {}); @@ -90,8 +98,7 @@ export const handler = async (event: any): Promise => { .selectFrom('branch.users') .selectAll() .execute(); - - console.log(users); + return json(200, { users }); } @@ -101,7 +108,7 @@ export const handler = async (event: any): Promise => { const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); if (authError) return authError; - if (!userId) return json(400, { message: 'userId is required' }); + 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' }); @@ -126,18 +133,19 @@ export const handler = async (event: any): Promise => { const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); if (authError) return authError; - if (!userId) return json(400, { message: 'userId is required' }); + 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: { email?: string; name?: string; is_admin?: boolean; profile_image?: string } = {}; + const updates: { name?: string; is_admin?: boolean; profile_image?: string } = {}; - const emailResult = UserValidationUtils.validateEmail(body.email); - if (!emailResult.isValid) return json(400, { message: emailResult.error }); - if (emailResult.value != null) updates.email = emailResult.value; + // 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 }); @@ -183,15 +191,34 @@ export const handler = async (event: any): Promise => { if (authError) return authError; const userId = normalizedPath.split('/')[1]; - if (!userId) return json(400, { message: 'userId is required' }); - + 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' }); } - return json(200, { ok: true, route: 'DELETE /users/{userId}', pathParams: { userId } }); + // 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 diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index 6efa5120..378c2932 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", @@ -32,10 +33,15 @@ "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, @@ -44,6 +50,278 @@ "version": "1.0.0", "dev": true }, + "node_modules/@aws-sdk/client-cognito-identity-provider": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1108.0.tgz", + "integrity": "sha512-5MpTd7MuRqkVbFlquv4pYBFVOfPpKTcGg7SDDPHcLJRG14qb7wtevqM8ohBD3ySmdHx0KE6eYAmqSrth2eJFzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-node": "^3.972.79", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1627,6 +1905,87 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", + "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -2335,6 +2694,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -5624,7 +5989,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-detect": { diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 72a66e8e..0e999510 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -24,6 +24,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/users/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index 56afd973..e60c7e7e 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -4,6 +4,17 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; jest.mock('../db'); jest.mock('../auth'); +// The DELETE route calls AdminDeleteUser; never let a test reach a real pool. +const mockSend = jest.fn(); + +jest.mock('@aws-sdk/client-cognito-identity-provider', () => { + const actual = jest.requireActual('@aws-sdk/client-cognito-identity-provider') as object; + return { + ...actual, + CognitoIdentityProviderClient: jest.fn(() => ({ send: mockSend })), + }; +}); + import { handler } from '../handler'; import db from '../db'; import { authenticateRequest, checkAuthorization } from '../auth'; @@ -501,19 +512,19 @@ describe('PATCH /users/{userId} unit tests', () => { mockExistingUserForPatch({ user_id: 1, name: 'Existing User', - email: 'new@example.com', + email: 'existing@example.com', is_admin: true, profile_image: null, }); - const res = await handler(patchEvent(1, { email: 'new@example.com', isAdmin: true })); + const res = await handler(patchEvent(1, { isAdmin: true })); expect(res.statusCode).toBe(200); const json = JSON.parse(res.body); expect(json).toHaveProperty('ok'); expect(json).toHaveProperty('route'); expect(json).toHaveProperty('body'); - expect(json.body.email).toBe('new@example.com'); + expect(json.body.email).toBe('existing@example.com'); expect(json.body.isAdmin).toBe(true); }); diff --git a/apps/backend/lambdas/users/test/users.test.ts b/apps/backend/lambdas/users/test/users.test.ts index e7f34a0b..d9f6ad89 100644 --- a/apps/backend/lambdas/users/test/users.test.ts +++ b/apps/backend/lambdas/users/test/users.test.ts @@ -1,3 +1,17 @@ +// DELETE /users/{userId} calls AdminDeleteUser, and CI supplies a real user +// pool id -- the client must never be the real one here. +const mockSend = jest.fn(); + +jest.mock('@aws-sdk/client-cognito-identity-provider', () => { + // Keep the real command classes so mockSend.mock.calls[n][0].input is + // assertable and constructor-level input validation still runs. + const actual = jest.requireActual('@aws-sdk/client-cognito-identity-provider'); + return { + ...actual, + CognitoIdentityProviderClient: jest.fn(() => ({ send: mockSend })), + }; +}); + import { Pool } from 'pg'; import { ensureSchema, resetData } from '../../../db/testkit'; import { handler } from '../handler'; @@ -145,16 +159,15 @@ test("patch user test 🌞", async () => { path: '/1', body: { name: "John Branch", - email: "mrbranch@example.com", isAdmin: false }, }); - + const res = await handler(patchEvent); expect(res.statusCode).toBe(200); - + const body = JSON.parse(res.body).body; - expect(body.email).toBe("mrbranch@example.com"); + expect(body.email).toBe(originalBody.email); expect(body.name).toBe("John Branch"); expect(body.isAdmin).toBe(false); } finally { @@ -164,7 +177,6 @@ test("patch user test 🌞", async () => { path: '/1', body: { name: originalBody.name, - email: originalBody.email, isAdmin: originalBody.isAdmin }, }); @@ -180,7 +192,6 @@ test("patch user profile_image test 🌞", async () => { path: '/1', body: { name: "Ashley Duggan", - email: "ashley@branch.org", isAdmin: true, profileImage: "https://s3.amazonaws.com/branch-avatars/ashley.png" }, diff --git a/apps/backend/lambdas/users/validation-utils.ts b/apps/backend/lambdas/users/validation-utils.ts index 6b512abf..e0f5bb13 100644 --- a/apps/backend/lambdas/users/validation-utils.ts +++ b/apps/backend/lambdas/users/validation-utils.ts @@ -20,7 +20,8 @@ export class UserValidationUtils { if (!this.EMAIL_REGEX.test(input)) { return { isValid: false, error: 'Invalid email format' }; } - return { isValid: true, value: input }; + // email is the Cognito username and is looked up lowercased elsewhere + return { isValid: true, value: input.trim().toLowerCase() }; } // Validates name - if provided, must be a non-empty string diff --git a/apps/frontend/src/app/reports/page.tsx b/apps/frontend/src/app/reports/page.tsx index 8664c845..86454366 100644 --- a/apps/frontend/src/app/reports/page.tsx +++ b/apps/frontend/src/app/reports/page.tsx @@ -80,8 +80,15 @@ function ReportsPageContent() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Delete/download failures — non-blocking, so they must not hide the table + const [actionError, setActionError] = useState(null); + // Selected rows (checkboxes) for bulk delete const [selectedIds, setSelectedIds] = useState([]); + const [deleting, setDeleting] = useState(false); + + // report_id whose download URL is currently being fetched + const [downloadingId, setDownloadingId] = useState(null); // Upload modal const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); @@ -94,13 +101,17 @@ function ReportsPageContent() { page: '', }); const currentPage = parseInt(filters.page, 10) || 1; - + const [totalPages, setTotalPages] = useState(1); + // Fetch reports async function fetchReports() { try { - const json = await api.get<{ data: Report[] }>('/reports'); + const json = await api.get<{ data: Report[]; pagination?: { totalPages: number } }>( + `/reports?page=${currentPage}&limit=${ROWS_PER_PAGE}`, + ); setReports(json.data ?? []); + setTotalPages(Math.max(1, json.pagination?.totalPages ?? 1)); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load reports'); } finally { @@ -136,27 +147,27 @@ function ReportsPageContent() { useEffect(() => { + // Selection is scoped to the visible page, so it must not survive a page + // change — bulk delete would otherwise remove rows the user can't see. + setSelectedIds([]); fetchReports(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPage]); + + useEffect(() => { fetchProjects(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Pagination - const totalPages = Math.max(1, Math.ceil(reports.length / ROWS_PER_PAGE)); - const paginatedData = reports.slice( - (currentPage - 1) * ROWS_PER_PAGE, - currentPage * ROWS_PER_PAGE, - ); - // Selection helpers (scoped to the currently visible page of rows) - const allSelected = paginatedData.length > 0 && paginatedData.every((r) => selectedIds.includes(r.report_id)); - const someSelected = paginatedData.some((r) => selectedIds.includes(r.report_id)) && !allSelected; + const allSelected = reports.length > 0 && reports.every((r) => selectedIds.includes(r.report_id)); + const someSelected = reports.some((r) => selectedIds.includes(r.report_id)) && !allSelected; function toggleAll() { if (allSelected) { - setSelectedIds((prev) => prev.filter((id) => !paginatedData.some((r) => r.report_id === id))); + setSelectedIds((prev) => prev.filter((id) => !reports.some((r) => r.report_id === id))); } else { - const pageIds = paginatedData.map((r) => r.report_id); + const pageIds = reports.map((r) => r.report_id); setSelectedIds((prev) => Array.from(new Set([...prev, ...pageIds]))); } } @@ -168,18 +179,41 @@ function ReportsPageContent() { } - // Bulk delete handler - // NOTE: DELETE /reports endpoint doesn't exist + // Bulk delete handler — the backend deletes one report per call async function handleDeleteSelected() { - console.log('Delete isn\'t available yet — no DELETE /reports endpoint exists on the backend.'); - /* await apiFetch('/reports', { - token, - method: 'DELETE', - body: JSON.stringify({ ids: selectedIds }), - }); - setSelectedIds([]); - await fetchReports(); - */ + if (selectedIds.length === 0 || deleting) return; + setDeleting(true); + setActionError(null); + try { + const results = await Promise.allSettled( + selectedIds.map((id) => api.del(`/reports/${id}`)), + ); + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed > 0) { + setActionError( + `Failed to delete ${failed} of ${selectedIds.length} report${selectedIds.length === 1 ? '' : 's'}`, + ); + } + setSelectedIds([]); + await fetchReports(); + } finally { + setDeleting(false); + } + } + + async function handleDownload(reportId: number) { + setDownloadingId(reportId); + setActionError(null); + try { + const { downloadUrl } = await api.get<{ downloadUrl: string; expiresIn: number }>( + `/reports/${reportId}/download`, + ); + window.open(downloadUrl, '_blank', 'noopener,noreferrer'); + } catch (err) { + setActionError(err instanceof Error ? err.message : 'Failed to open report'); + } finally { + setDownloadingId(null); + } } function handleNewReport() { @@ -232,7 +266,8 @@ function ReportsPageContent() { backgroundColor="var(--color-error-red)" color="var(--color-core-white)" onClick={handleDeleteSelected} - disabled={selectedIds.length === 0} + loading={deleting} + disabled={selectedIds.length === 0 || deleting} > Delete @@ -265,6 +300,9 @@ function ReportsPageContent() { {/* Loading / Error */} {loading &&

Loading reports...

} {error &&

{error}

} + {actionError && ( +

{actionError}

+ )} {/* Reports tab content */} {!loading && !error && activeTab === 'reports' && ( @@ -305,14 +343,14 @@ function ReportsPageContent() { - {paginatedData.length === 0 && ( + {reports.length === 0 && ( No reports found. )} - {paginatedData.map((report) => ( + {reports.map((report) => ( {formatDate(report.date_created)} - {report.title || 'Untitled report'} + + + {report.emails && report.emails.length > 0 ? report.emails.join(', ') : '—'} diff --git a/apps/frontend/test/components/ReportsPage.test.tsx b/apps/frontend/test/components/ReportsPage.test.tsx index cdb107ba..2132a9f1 100644 --- a/apps/frontend/test/components/ReportsPage.test.tsx +++ b/apps/frontend/test/components/ReportsPage.test.tsx @@ -43,8 +43,11 @@ function mockApiFetchImplementation({ projects = mockProjects, }: { reports?: typeof mockReports; projects?: typeof mockProjects } = {}) { (apiFetch as jest.Mock).mockImplementation((endpoint: string) => { - if (endpoint === '/reports') { - return Promise.resolve({ data: reports }); + if (endpoint.startsWith('/reports?')) { + return Promise.resolve({ + data: reports, + pagination: { page: 1, limit: 10, totalItems: reports.length, totalPages: 1 }, + }); } if (endpoint === '/projects') { return Promise.resolve(projects); @@ -63,7 +66,9 @@ describe('ReportsPage', () => { mockApiFetchImplementation(); render(); expect(screen.getByRole('heading', { name: 'Reports', level: 1 })).toBeInTheDocument(); - await waitFor(() => expect(apiFetch).toHaveBeenCalledWith('/reports', expect.anything())); + await waitFor(() => + expect(apiFetch).toHaveBeenCalledWith('/reports?page=1&limit=10', expect.anything()), + ); }); it('renders a row for each fetched report', async () => { @@ -83,7 +88,7 @@ describe('ReportsPage', () => { it('shows an error message when fetching reports fails', async () => { (apiFetch as jest.Mock).mockImplementation((endpoint: string) => { - if (endpoint === '/reports') { + if (endpoint.startsWith('/reports?')) { return Promise.reject(new Error('Failed to load reports')); } return Promise.resolve(mockProjects); @@ -171,7 +176,12 @@ describe('ReportsPage', () => { it('calls POST /reports/generate with the selected project and file type', async () => { const user = userEvent.setup(); (apiFetch as jest.Mock).mockImplementation((endpoint: string) => { - if (endpoint === '/reports') return Promise.resolve({ data: mockReports }); + if (endpoint.startsWith('/reports?')) { + return Promise.resolve({ + data: mockReports, + pagination: { page: 1, limit: 10, totalItems: mockReports.length, totalPages: 1 }, + }); + } if (endpoint === '/projects') return Promise.resolve(mockProjects); if (endpoint === '/reports/generate') return Promise.resolve({ ok: true }); return Promise.resolve({}); diff --git a/infrastructure/AGENTS.md b/infrastructure/AGENTS.md index ccec998a..5c13ad94 100644 --- a/infrastructure/AGENTS.md +++ b/infrastructure/AGENTS.md @@ -9,10 +9,10 @@ Common to all modules: Terraform **1.13.0** (`.terraform-version`, tfenv), state ### `aws/` (state key `aws/terraform.tfstate`) Application infra. Providers: AWS 6.14.1, Infisical. - `main.tf` — RDS PostgreSQL 17.6 (db.t3.micro), `branch_rds` db; creds from Infisical `/aws/rds`. -- `lambda.tf` — 6 Lambda functions (auth/donors/expenditures/projects/reports/users, Node 20.x, 256MB, 30s), IAM role (CloudWatch Logs + pool-scoped `cognito-idp:AdminDeleteUser`/`AdminGetUser` for the registration-rollback path), deployment S3 bucket. **`lifecycle` ignores `s3_key` only** — code is deployed by CI (`lambda-deploy`), not Terraform. Env: `NODE_ENV`, `DB_*`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID`, `REPORTS_BUCKET_NAME`. **The `environment` block is authoritative:** any var set by hand in the console and not declared here is deleted on the next apply, which previously took out authentication across all six lambdas. `AWS_REGION` is Lambda-reserved and must stay absent. +- `lambda.tf` — 6 Lambda functions (auth/donors/expenditures/projects/reports/users, Node 20.x, 256MB, 30s), IAM role (CloudWatch Logs + pool-scoped `cognito-idp:AdminDeleteUser`/`AdminGetUser` for the registration-rollback and `DELETE /users/{userId}` paths + `s3:PutObject`/`s3:GetObject` on the reports bucket), deployment S3 bucket. **`lifecycle` ignores `s3_key` only** — code is deployed by CI (`lambda-deploy`), not Terraform. Env: `NODE_ENV`, `DB_*`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID`, `REPORTS_BUCKET_NAME`. **The `environment` block is authoritative:** any var set by hand in the console and not declared here is deleted on the next apply, which previously took out authentication across all six lambdas. `AWS_REGION` is Lambda-reserved and must stay absent. - `cognito.tf` — user pool (email sign-in, auto-verify, 8-char password policy, `advanced_security_mode = AUDIT`, `mfa_configuration = OFF`, deletion protection) + public client (1h access/ID tokens, 30d refresh, no secret). Outputs `cognito_user_pool_id` / `cognito_client_id`; the lambdas get these from `lambda.tf` directly, no manual step. Infisical `/aws/cognito/` is still the source for the `COGNITO_*` GitHub Actions secrets used by `lambda-tests.yml` — keep it in sync if the pool is ever recreated. Threat protection is AUDIT rather than ENFORCED because every sign-in is proxied through the auth lambda, so adaptive auth would risk-score one shared ENI address. Enabling MFA later is a change here only; the backend already handles the challenges. - `api_gateway.tf` — REST API, one resource per lambda, method routing, `AWS_PROXY` integration, `prod` stage. -- `s3.tf` — public-read reports bucket + versioned/encrypted lambda-deployments bucket. +- `s3.tf` — private reports bucket + versioned/encrypted lambda-deployments bucket. The reports bucket was public-read until the lambda role gained `s3:GetObject`; reports are now served only through the presigned `GET /reports/{id}/download`, so nothing may reintroduce a public bucket policy. - `frontend_hosting.tf` — static frontend: private S3 bucket + CloudFront (OAC) with an SPA fallback (403/404 → `/index.html`) and an index-rewrite CloudFront Function. The Next.js app is exported (`output: 'export'`) and synced to S3 by the `frontend-deploy` workflow. - `oidc.tf` — GitHub OIDC provider + `branch-ci-plan` (read-only) / `branch-ci-apply` (write, `production` env only) roles for CI. - `secrets.tf`, `variables.tf` — Infisical data sources. diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index d83d4dc2..87f97ab7 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -59,7 +59,6 @@ No modules. | [aws_s3_bucket.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.reports_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket_policy.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_policy) | resource | -| [aws_s3_bucket_policy.reports_bucket_policy](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_policy) | resource | | [aws_s3_bucket_public_access_block.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_public_access_block.reports_bucket_public_access](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_server_side_encryption_configuration.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_server_side_encryption_configuration) | resource | diff --git a/infrastructure/aws/lambda.tf b/infrastructure/aws/lambda.tf index f5111f5a..f7f7a9da 100644 --- a/infrastructure/aws/lambda.tf +++ b/infrastructure/aws/lambda.tf @@ -17,11 +17,11 @@ resource "aws_iam_role_policy_attachment" "lambda_basic" { policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } -# The auth lambda's registration-rollback path calls AdminDeleteUser when the -# branch.users write fails after a successful Cognito SignUp. Without this it -# fails AccessDeniedException and orphans a Cognito user with no DB row: that -# user can never log in (authenticate.ts finds no row) and re-registering -# returns 409 from Cognito. +# Two paths call AdminDeleteUser: the auth lambda's registration rollback, when +# the branch.users write fails after a successful Cognito SignUp, and the users +# lambda's DELETE /users/{userId}. Without this it fails AccessDeniedException +# and orphans a Cognito user with no DB row: that user can never log in +# (authenticate.ts finds no row) and re-registering returns 409 from Cognito. # # Every other Cognito API the auth lambda uses (SignUp, InitiateAuth, # RespondToAuthChallenge, ConfirmSignUp, ResendConfirmationCode, @@ -46,9 +46,11 @@ resource "aws_iam_role_policy" "lambda_cognito_admin" { }) } -# The expenditures lambda presigns receipt uploads and downloads. A presigned -# URL carries the signer's permissions, so the role needs both PutObject and -# GetObject or the browser's PUT/GET fails AccessDenied. +# The role had no S3 permissions at all, so report-service.ts's PutObject failed +# AccessDeniedException on every POST /reports/generate. GetObject is needed too: +# a presigned URL carries the signer's permissions, so neither the expenditures +# lambda's receipt PUT/GET nor GET /reports/{id}/download can mint a working link +# unless this role may itself read and write the object. resource "aws_iam_role_policy" "lambda_s3_objects" { name = "branch-lambda-s3-objects" role = aws_iam_role.lambda_role.id diff --git a/infrastructure/aws/s3.tf b/infrastructure/aws/s3.tf index 4d43abe7..b3e01c52 100644 --- a/infrastructure/aws/s3.tf +++ b/infrastructure/aws/s3.tf @@ -5,28 +5,10 @@ resource "aws_s3_bucket" "reports_bucket" { resource "aws_s3_bucket_public_access_block" "reports_bucket_public_access" { bucket = aws_s3_bucket.reports_bucket.id - block_public_acls = false - block_public_policy = false - ignore_public_acls = false - restrict_public_buckets = false -} - -resource "aws_s3_bucket_policy" "reports_bucket_policy" { - bucket = aws_s3_bucket.reports_bucket.id - depends_on = [aws_s3_bucket_public_access_block.reports_bucket_public_access] - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "PublicReadGetObject" - Effect = "Allow" - Principal = "*" - Action = "s3:GetObject" - Resource = "${aws_s3_bucket.reports_bucket.arn}/*" - } - ] - }) + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true } output "reports_bucket_name" { diff --git a/shared/lambda-auth/package-lock.json b/shared/lambda-auth/package-lock.json index db4e4e9a..a6dd3bdf 100644 --- a/shared/lambda-auth/package-lock.json +++ b/shared/lambda-auth/package-lock.json @@ -8,6 +8,7 @@ "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { @@ -19,6 +20,10 @@ "typescript": "^5.4.5" } }, + "../types": { + "name": "@branch/types", + "version": "1.0.0" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -515,6 +520,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@branch/types": { + "resolved": "../types", + "link": true + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/shared/lambda-auth/package.json b/shared/lambda-auth/package.json index 879e1cba..a4c7238d 100644 --- a/shared/lambda-auth/package.json +++ b/shared/lambda-auth/package.json @@ -9,6 +9,7 @@ "test": "jest" }, "dependencies": { + "@branch/types": "file:../types", "aws-jwt-verify": "^5.1.1" }, "devDependencies": { diff --git a/shared/lambda-auth/src/types.ts b/shared/lambda-auth/src/types.ts index 9e3d8237..e3ec6827 100644 --- a/shared/lambda-auth/src/types.ts +++ b/shared/lambda-auth/src/types.ts @@ -1,19 +1,8 @@ -export interface AuthenticatedUser { - cognitoSub: string; - userId?: number; - email?: string; - isAdmin: boolean; - cognitoGroups?: string[]; -} - -export interface AuthContext { - user?: AuthenticatedUser; - isAuthenticated: boolean; -} - -export type AccessLevel = 'PUBLIC' | 'AUTHENTICATED' | 'ADMIN' | 'SELF' | 'ADMIN_OR_SELF'; - -export interface AuthorizationCheck { - allowed: boolean; - reason?: string; -} +// Declared once in @branch/types and re-exported here so runtime consumers get +// the DTOs from the package that produces them. +export type { + AccessLevel, + AuthContext, + AuthenticatedUser, + AuthorizationCheck, +} from '@branch/types'; diff --git a/shared/types/README.md b/shared/types/README.md index 89e7b95d..ee70405a 100644 --- a/shared/types/README.md +++ b/shared/types/README.md @@ -9,9 +9,14 @@ Shared type definitions for the Branch lambdas. This replaces the per-lambda cop | `db-types.d.ts` | Kysely row types generated from `apps/backend/db/migrations/**` (`DB`, `BranchUsers`, `BranchProjects`, ...) | | `auth-types.d.ts` | Auth DTOs (`AuthenticatedUser`, `AuthContext`, `AccessLevel`, `AuthorizationCheck`) | +`auth-types.d.ts` is the **single declaration** of those DTOs. `@branch/lambda-auth` depends on this +package and re-exports them from its own `src/types.ts`, so both packages always agree by +construction. Never add a second copy — a previous duplicate had already drifted (`isAdmin` was +optional here and required there). + ## How it works -The package is **types-only** — it contains no runtime code and has no dependencies. Each lambda references it via a `file:` dependency in its `package.json`: +The package is **types-only** — it contains no runtime code and has no dependencies, which is what lets `@branch/lambda-auth` depend on it without creating a cycle. Keep it a leaf. Each lambda references it via a `file:` dependency in its `package.json`: ```json "devDependencies": { diff --git a/shared/types/auth-types.d.ts b/shared/types/auth-types.d.ts index eccc244e..33538659 100644 --- a/shared/types/auth-types.d.ts +++ b/shared/types/auth-types.d.ts @@ -1,12 +1,13 @@ /** - * Shared auth DTOs used by every lambda's auth.ts. + * The single declaration of the auth DTOs. @branch/lambda-auth re-exports these + * rather than declaring its own copy. */ export interface AuthenticatedUser { cognitoSub: string; userId?: number; email?: string; - isAdmin?: boolean; + isAdmin: boolean; cognitoGroups?: string[]; }