Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/lambda-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ jobs:

- name: Build shared lambda-auth package
run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth
- name: Build shared lambda-http package
run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http
- name: Install dependencies
working-directory: ${{ matrix.lambda }}
run: npm ci --legacy-peer-deps

- name: Package lambda
working-directory: ${{ matrix.lambda }}
run: npm run package
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/lambda-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ jobs:
run: psql postgres://branch_dev:password@localhost:5432/branch_db -f apps/backend/db/db_setup.sql
- name: Build shared lambda-auth package
run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth
- name: Build shared lambda-http package
run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http
- name: Install dependencies
working-directory: ${{ matrix.lambda }}
run: npm ci --legacy-peer-deps
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
dist
tmp
/out-tsc
lambda.zip

# dependencies
node_modules
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi
| `apps/backend/lambdas/` | The lambda services + `lambda-cli.js` tooling | `apps/backend/lambdas/AGENTS.md` |
| `shared/types/` | `@branch/types` — types-only pkg (DB rows + auth DTOs) | see backend doc |
| `shared/lambda-auth/` | `@branch/lambda-auth` — runtime Cognito auth/authz pkg | see backend doc |
| `shared/lambda-http/` | `@branch/lambda-http` — runtime HTTP router/dispatcher for lambdas | see backend doc |
| `infrastructure/` | Terraform: `aws/`, `github/`, `test/` | `infrastructure/AGENTS.md` |
| `.github/workflows/` | CI/CD + PR review bot | `.github/AGENTS.md` |

Expand All @@ -28,10 +29,11 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi

## Shared packages (critical)

Two `file:`-linked packages dedupe code across lambdas:
Three `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/db_setup.sql` by the `regenerate-db-types` workflow — 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`.
- **`@branch/lambda-http`** (`shared/lambda-http/`) — runtime HTTP router: `dispatch(event, { prefix, routes })`, `json(status, body)`, `matchPattern`. Each lambda is a `routes` table of `{ method, pattern, handler }` with full prefixed `:param` patterns; `dispatch` canonicalizes the path (so the same table works behind API Gateway's full path and the dev-server's stripped path) and centralizes OPTIONS/CORS, `/health`, 404 and 500. Runtime deps are `file:`-linked but **bundled into each lambda's zip by esbuild** at `npm run package` — they are not on `node_modules` at runtime.

## Root commands

Expand Down
16 changes: 10 additions & 6 deletions apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@ make logs-service SERVICE=users
```
Defaults work without `.env` (DB: branch_dev/password@postgres:5432/branch_db). See `apps/backend/README.md` for the full Make target table.

**Shared dev-server (single service iteration)** — from a lambda dir (`npm run dev`). All lambdas register on **port 3000**; first one started owns the server, others register via `POST /_register`. Routes dispatch by first path segment:
**Shared dev-server (single service iteration)** — from a lambda dir (`npm run dev`). All lambdas register on **port 3000**; first one started owns the server, others register via `POST /_register`. It routes by first path segment to a service, then **strips that prefix** before invoking the handler:
```
http://localhost:3000/auth/register
http://localhost:3000/donors # GET /
http://localhost:3000/auth/register # handler receives /register
http://localhost:3000/donors # handler receives /
http://localhost:3000/<service>/swagger # Swagger UI from openapi.yaml
http://localhost:3000/<service>/health
```
The `@branch/lambda-http` dispatcher **re-canonicalizes** the stripped path back to the full prefixed form (`/register` → `/auth/register`), so the same route table matches here and behind API Gateway (which forwards the full path).

## Database

Expand All @@ -49,9 +50,12 @@ http://localhost:3000/<service>/health

## Shared packages

Both linked via `file:` deps in each lambda's `package.json`:
All 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/lambda-auth` (`../../../../shared/lambda-auth`) — dependency, runtime auth. Build it (`npm run build` in `shared/lambda-auth`) when its source changes.
- `@branch/lambda-http` (`../../../../shared/lambda-http`) — dependency, runtime HTTP router (`dispatch`/`json`/`matchPattern`). Build it when its source changes.

Runtime shared deps are **bundled into each lambda's zip by esbuild** (see Deploy), so they don't need to be present in `node_modules` at runtime — but they must be built (`dist/`) before bundling.

## Deploy

Expand All @@ -60,7 +64,7 @@ Automatic on push to `main` touching `apps/backend/lambdas/**` or `shared/types/
2. Build per-lambda: `npm ci --legacy-peer-deps` + `npm run package` → `lambda.zip`.
3. `aws lambda update-function-code --function-name branch-<name>` (region `us-east-2`).

`npm run package` = `tsc` then zip `dist/` excluding maps, `dev-server.*`, `swagger-utils.*`. Function names **must** match `branch-<service>`. Infra (function definitions, IAM, API Gateway routes) is in `infrastructure/aws/lambda.tf` + `api_gateway.tf`; the deploy workflow only swaps code (TF `lifecycle` ignores `s3_key`).
The workflow builds `shared/lambda-auth` and `shared/lambda-http` first. `npm run package` = **esbuild bundle** of `handler.ts` (deps + shared packages inlined, `@aws-sdk/*` external) → single `dist/handler.js`, zipped to `lambda.zip`. Function names **must** match `branch-<service>`. Infra (function definitions, IAM, API Gateway routes) is in `infrastructure/aws/lambda.tf` + `api_gateway.tf`; the deploy workflow only swaps code (TF `lifecycle` ignores `s3_key`). API Gateway forwards the **full path** to each lambda via a greedy `{proxy+}` per service (see `infrastructure/AGENTS.md`).

## Env vars (lambdas)

Expand Down
4 changes: 2 additions & 2 deletions apps/backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ services:
# Auth Service
auth:
build:
context: ./lambdas/auth
dockerfile: Dockerfile
context: ../..
dockerfile: apps/backend/lambdas/auth/Dockerfile
container_name: branch-auth
restart: unless-stopped
environment:
Expand Down
81 changes: 48 additions & 33 deletions apps/backend/lambdas/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Each `<service>/` here is one Lambda. They share a near-identical shape. **Use t

```
<service>/
handler.ts # entry: export const handler = async (event) => ...
handler.ts # entry: route table dispatched via @branch/lambda-http
dev-server.ts # local shared-server registration (port 3000)
db.ts # Kysely<DB> + pg.Pool
auth.ts # thin wrapper over @branch/lambda-auth + domain authz helpers
Expand All @@ -20,35 +20,40 @@ Each `<service>/` here is one Lambda. They share a near-identical shape. **Use t

## Handler pattern

Each handler is a **route table** dispatched by `@branch/lambda-http`. Business
logic lives in per-route functions; `dispatch` owns path/method parsing, OPTIONS
preflight, `/<prefix>/health`, 404 and 500.

```ts
export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
try {
const rawPath = event.rawPath || event.path || '/';
const normalizedPath = rawPath.replace(/\/$/, '');
const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase();

if (method === 'OPTIONS') return json(200, {}); // CORS preflight
if (normalizedPath.endsWith('/health') && method === 'GET') // health (no auth)
return json(200, { ok: true });

const authContext = await authenticateRequest(event); // every service except `auth`
if (!authContext.isAuthenticated) return json(401, { message: 'Unauthorized' });

// >>> ROUTES-START (do not remove this marker)
if (normalizedPath === '/donors' && method === 'GET') { /* ... */ }
// <<< ROUTES-END

return json(404, { message: 'Not Found' });
} catch (err) {
console.error('Lambda error:', err);
return json(500, { message: 'Internal Server Error' });
}
};
import { dispatch, json, RouteCtx } from '@branch/lambda-http';

async function listDonors({ event }: RouteCtx): Promise<APIGatewayProxyResult> {
const authContext = await authenticateRequest(event); // every service except `auth`
if (!authContext.isAuthenticated) return json(401, { message: 'Unauthorized' });
// ...
return json(200, { data: [] });
}

export const handler = (event: any): Promise<APIGatewayProxyResult> =>
dispatch(event, {
prefix: 'donors',
routes: [
{ method: 'GET', pattern: '/donors', handler: listDonors },
{ method: 'GET', pattern: '/donors/:id', handler: getDonor }, // ctx.params.id
],
});
```

- **NEVER remove or modify the `ROUTES-START` / `ROUTES-END` markers** — the CLI injects routes between them.
- Handles both API Gateway and Lambda Function URL event shapes (`rawPath`/`path`, `requestContext.http.method`/`httpMethod`).
- Responses go through a local `json(status, body)` helper that sets CORS headers (`Access-Control-Allow-Origin: *`, allowed headers `Content-Type,Authorization`).
- **Patterns are full prefixed paths** (`/donors`, `/projects/:id/members`), with
`:param` segments surfaced as `ctx.params`. Routes are tried in order — list
more specific patterns first (e.g. `/projects/dashboard` before `/projects/:id`).
- `dispatch` **canonicalizes** the path: API Gateway delivers the full path, the
shared dev-server strips the service prefix — both resolve to the same pattern.
- CORS is centralized: `json()` sets the headers and OPTIONS returns 200. Auth is
still per-route (call `authenticateRequest` inside each handler that needs it).
- The old per-handler `if`-chain + `ROUTES-START/END` markers + local `json()` are
gone. Scaffolds created by `lambda-cli` wrap the routes array in the markers so
`add-route` can inject entries; hand-converted handlers omit them.

## Auth & authorization

Expand Down Expand Up @@ -92,11 +97,18 @@ Convention: optional `page` + `limit` query params → `offset = (page-1)*limit`

```
dev ts-node --transpile-only dev-server.ts
build tsc
package npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'
build tsc # typecheck only
package esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs \
--outfile=dist/handler.js --external:@aws-sdk/* && zip the single dist/handler.js
test jest (or start-server-and-test wrapping jest)
```

`package` **bundles** the handler + all `file:`-linked shared packages
(`@branch/lambda-http`, `@branch/lambda-auth`) and node deps into one
`dist/handler.js` via esbuild (`@aws-sdk/*` left external — provided by the
node20 runtime). The zip ships only that file. The CI `lambda-deploy` /
`lambda-tests` workflows build `shared/lambda-http` before installing each lambda.

---

# Lambda CLI
Expand All @@ -106,14 +118,17 @@ When adding new API endpoints or scaffolding new Lambda handlers, use the CLI at
## Commands

### `init-handler <name>`
Creates a new Lambda handler with boilerplate (handler.ts, dev-server.ts, openapi.yaml, swagger-utils.ts, package.json, tsconfig.json, README.md, test/). Wires in `@branch/types` and `@branch/lambda-auth` automatically.
Creates a new Lambda handler with boilerplate (handler.ts, dev-server.ts, openapi.yaml, swagger-utils.ts, package.json, tsconfig.json, README.md, test/). Wires in `@branch/types`, `@branch/lambda-auth`, and `@branch/lambda-http` automatically. The scaffolded `handler.ts` is a `dispatch(...)` route table with an empty `routes` array between the `ROUTES-START/END` markers.

```bash
node tools/lambda-cli.js init-handler orders
```

### `add-route <handler> <METHOD> <path> [options]`
Adds a route stub to both `handler.ts` (between the ROUTES-START/ROUTES-END markers) and `openapi.yaml`.
Injects a route-table entry (a `{ method, pattern, handler }` object with a stub
handler) into `handler.ts` between the ROUTES-START/ROUTES-END markers, and the
path into `openapi.yaml`. **Pass the full prefixed path** — `{id}` is converted to
the dispatcher's `:id` pattern.

Options:
- `--body field:type,field:type` — request body fields
Expand All @@ -122,8 +137,8 @@ Options:
- `--status <code>` — response status code (default: 200)

```bash
node tools/lambda-cli.js add-route auth POST /reset-password --body email:string,code:string,newPassword:string
node tools/lambda-cli.js add-route users GET /users/{id}
node tools/lambda-cli.js add-route auth POST /auth/reset-password --body email:string,code:string,newPassword:string
node tools/lambda-cli.js add-route users GET /users/{userId}
node tools/lambda-cli.js add-route users GET /users --query page:number,limit:number
node tools/lambda-cli.js add-route users POST /users --body name:string --headers authorization:string --status 201
```
Expand Down
20 changes: 10 additions & 10 deletions apps/backend/lambdas/auth/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
FROM node:20-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./
# Build context is the repo root (see docker-compose.yml) so the file: shared
# deps resolve. Build the shared HTTP router into /shared/lambda-http first.
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

# Install dependencies
RUN npm install

# Copy source files
COPY . .
WORKDIR /app
COPY apps/backend/lambdas/auth/package*.json ./
RUN npm install --no-package-lock
COPY apps/backend/lambdas/auth/ .

# Expose port
EXPOSE 3000

# Health check
Expand Down
14 changes: 7 additions & 7 deletions apps/backend/lambdas/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ Lambda for auth handler.
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| POST | /register | |
| POST | /login | |
| POST | /verify-email | |
| POST | /resend-code | |
| POST | /logout | |
| POST | /forgot-password | |
| POST | /reset-password | |
| POST | /auth/register | |
| POST | /auth/login | |
| POST | /auth/verify-email | |
| POST | /auth/resend-code | |
| POST | /auth/logout | |
| POST | /auth/forgot-password | |
| POST | /auth/reset-password | |

## Setup

Expand Down
Loading
Loading