Skip to content

chore(lambdas): rebuild lambda-cli around route tables + normalize specs [8/8] - #348

Merged
nourshoreibah merged 44 commits into
mainfrom
refactor/lambda-cli
Aug 23, 2026
Merged

chore(lambdas): rebuild lambda-cli around route tables + normalize specs [8/8]#348
nourshoreibah merged 44 commits into
mainfrom
refactor/lambda-cli

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Stack 8/8, last one. Base is #347, not main. No lambda runtime code changes here — this is the code generator, the OpenAPI specs and the docs catching up to the seven PRs beneath it.

Without this, node tools/lambda-cli.js add-route keeps emitting if (normalizedPath === ...) blocks into a handler.ts that no longer has a routing chain, and apps/backend/lambdas/AGENTS.md keeps documenting an architecture that no longer exists.

CLI

  • templateHandlerTs / templateHandlerTsClean now emit the 4-line dispatch handler, plus new templateRoutesTs and templateControllerFile. The generated local json() and the inline OPTIONS/health/404/500 preamble are gone — the shared package owns all four.
  • addRouteToHandleraddRouteToRoutes: inserts a table entry, auto-prefixes the path with the service name, converts {param}:param, scaffolds a stub RouteHandler in controllers/, and keeps the import line in routes.ts in sync (dedup'd, collision-safe naming). The old normalizedPath.split('/')[N] param codegen is deleted outright — params arrive as ctx.params.
  • extractRoutesFromHandlerextractRoutesFromRoutes: reads the table from routes.ts instead of parsing if conditions. Deliberately independent of marker placement, because projects/routes.ts brackets the whole const routes = [...] while the other five bracket just the entries.
  • extractRoutesFromOpenApi normalizes {param}:param so specs and tables are comparable; checkSimilarRoutes's near-duplicate warning updated for :-prefixed params.
  • list-routes and README generation now source from routes.ts only. Health/OPTIONS/404/500 are dispatch-level, so they are not table entries and not counted.
  • Scaffolding gaps fixed along the way: init-handler never wrote a db.ts that its own generated auth.ts imports, so a freshly scaffolded lambda didn't typecheck. Added templateDbTs, plus kysely/pg/@types/pg/@branch/lambda-http in the generated package.json and controllers/**/*.ts in the generated tsconfig.json.

OpenAPI normalization

The six conversions deliberately left openapi.yaml alone, so several drifted from their route tables. All six are now on one convention: paths always full-prefixed, servers: always bare host. Mixing the two is what caused the mess — several specs had the prefix baked into servers and partially into the paths, so Swagger UI's "Try it out" would double it.

Fixed, per service:

Service Was
auth paths prefix-stripped (/login), prefix hidden in servers
donors prefix in servers, paths bare; missing GET /donors/donations (implemented + tested)
expenditures same shape; missing GET /expenditures/upload-url and GET /expenditures/{id}/receipt (both implemented)
projects /health and /dashboard unprefixed
reports /health unprefixed; two servers entries both suffixed /reports
users internally contradictory/users prefixed but /{userId} bare

users was reported as already-consistent by an earlier pass; it wasn't. Worth noting as a reminder that "verify rather than assume" earned its keep here.

Docs

  • apps/backend/lambdas/AGENTS.md — "Lambda anatomy" and "Handler pattern" rewritten for the route table, RouteCtx/params, and @branch/lambda-http; add-route docs updated for the new output.
  • Root AGENTS.md and apps/backend/AGENTS.md — both omitted @branch/lambda-http from the shared-package inventory ("two packages" → "three").
  • A stale projects/handler.ts:83 reference to the dashboard's "JS-loop" aggregation is corrected: that logic is now controllers/dashboard.ts, and it already aggregates in SQL (monthExpr + groupBy), so AGENTS.md's counter-example was doubly out of date. Verified before changing it.
  • All six READMEs regenerated; confirmed they regenerate byte-identically, so the lambda-readme CI check stays green.

Verification

  • Round-trip: init-handler scratchsvcadd-route with a path param → add-route with a body → list-routes listed both correctly → npm install && npx tsc --noEmit exited 0 against the real built @branch/lambda-http. A generator that emits non-compiling code is the main risk here, so this is the check that matters. Duplicate-route detection blocks an exact re-add and warns-but-proceeds on a param-name-only variant. scratchsvc deleted, confirmed absent from git status.
  • list-routes against all six real lambdas: projects 11, auth 10, reports 7, expenditures 7, donors 6, users 5 — exact match to the tables. This is the real test of the routes.ts parser.
  • Spec/table agreement: every spec parses via js-yaml, and comparing all path+verb pairs against every route table shows zero entries in a spec but not a table, or vice versa, for all six services.

Left alone

reports/openapi.yaml keeps a vestigial http://localhost:3005 server entry that no dev-server code references. Flagged rather than removed — deleting it is unrelated to this PR.

🤖 Generated with Claude Code

nourshoreibah and others added 15 commits August 22, 2026 13:48
No lambda is converted yet -- this only lands the package the conversions
build on, so it is a pure addition.

The six handlers each route with a chain of `if (normalizedPath === ...)`
statements that test two or three path spellings per route, because API
Gateway's {proxy+} forwards the full path (/projects/7) while the shared
dev-server strips the first segment (/7). Params come out of hand-rolled
`split('/')[2]` and regex tests, correctness depends on `if` ordering that
nothing enforces, and `json()` is defined six times over with `requireAuth`
three times.

@branch/lambda-http replaces that with a declarative route table:

- dispatch({ prefix, routes }) canonicalizes the path to the prefixed shape so
  one table serves both callers, matches `:param` segments, and centralizes
  OPTIONS preflight, /<prefix>/health, 404 and 500.
- json() with CORS headers, parseBody(), requireAuth() and a createAuthGuard()
  factory that binds a service's db-scoped authenticateRequest.
- 28 unit tests, including route precedence and both path shapes.

The dispatch/match/response/types modules are recovered from the closed PR
#257; the auth and body helpers are new. No infrastructure change is needed --
{proxy+} and ANY already landed on main via PR #279.

CI: both workflows build lambda-http after lambda-auth (it consumes that
package's dist), a shared-http job runs its tests and is added to the
lambda-tests gate, and lambda-deploy triggers on shared/lambda-http/**.

Note: lambda-deploy still does not trigger on shared/lambda-auth/**, a
pre-existing gap left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure reorganization, no behaviour change:
- handler.ts is now a one-liner: dispatch(event, { prefix: 'users', routes }).
- routes.ts holds the ordered Route[] table, bracketed by the ROUTES-START/
  ROUTES-END markers (moved here from handler.ts) in the same order as the
  original if-chain: GET /users, GET /users/:userId, PATCH /users/:userId,
  DELETE /users/:userId, POST /users.
- controllers/users.ts holds one RouteHandler per route, calling Kysely
  directly (no services/ layer — this lambda is thin). Auth now goes through
  createAuthGuard(authenticateRequest) from @branch/lambda-http instead of a
  handler-local requireAuth/checkAuthorization pairing; @branch/lambda-auth's
  checkAuthorization (which the shared requireAuth calls) is behaviourally
  identical to the removed local copy for every level this lambda uses.
- Local json()/requireAuth() helpers deleted in favor of the @branch/lambda-http
  exports. dev-server.ts, db.ts, auth.ts, validation-utils.ts, swagger-utils.ts
  untouched.
- Added @branch/lambda-http as a dependency, regenerated package-lock.json.
- tsconfig.json now includes controllers/**/*.ts.
- Added a route-precedence unit test (literal /users/me vs /users/:userId).

Existing suites pass unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the if-chain in handler.ts with a Route[] table dispatched via
@branch/lambda-http's dispatch(). handler.ts is now a one-liner; route
logic moved into controllers/donors.ts (GET/POST /donors, DELETE
/donors/:id) and controllers/donations.ts (GET/POST /donors/donations,
DELETE /donors/donations/:id), in the same order as the original if
chain. No services/ layer added, per this lambda's existing shape.

Local json() removed in favor of the shared one; auth stays manual
(authenticateRequest + custom 401/403 messages) since this lambda's
authorization messages don't match @branch/lambda-http's generic
requireAuth reasons. ROUTES-START/END markers moved into routes.ts,
now bracketing the route table entries.

Added @branch/lambda-http as a dependency and regenerated
package-lock.json. Added one test asserting GET /donors/donations
reaches the donations controller rather than a donor-id route.

No behavior change: same status codes, messages, and validation
order. Verified via tsc --noEmit and jest (--runInBand to avoid
DB contention with sibling lambda test runs): 51 passed, 1
pre-existing failure (health test requires a live dev-server on
:3000, fails identically on main).
Replaces the if-chain in handler.ts with a Route[] table (routes.ts) and
one RouteHandler per route (controllers/reports.ts). handler.ts is now
a thin `dispatch(event, { prefix: 'reports', routes })`.

- Local json() and the local async requireAuth() are gone; dispatch
  provides json/OPTIONS/health/404/500 centrally, and
  createAuthGuard(authenticateRequest) replaces the local requireAuth,
  preserving its exact 401 "Authentication required" message.
- Route order preserved from the original if-chain: POST /generate and
  GET /upload-url stay ahead of the /:id pattern they'd otherwise be
  swallowed by (both are 2-segment paths, same as /reports/:id).
- REPORT_ID_ROUTE/REPORT_DOWNLOAD_ROUTE's \d+ constraint is now an
  explicit numeric check in getReport/deleteReport/downloadReport, so a
  non-numeric :id still falls through to the same 404 instead of being
  looked up as a report id.
- report-service.ts is untouched; controllers still parse/validate/
  respond and delegate to it. S3 presigning stays in the controllers,
  matching where it lived in handler.ts.
- Added two route-precedence unit tests (GET /reports/upload-url and
  POST /reports/generate each reach their own controller, not
  /reports/:id or the generic POST /reports controller).
- ROUTES-START/END markers moved into routes.ts around the route array.

No behaviour change: same status codes, messages, validation order,
and S3/TTL values as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ambda-http

Replaces the if-chain in handler.ts with dispatch() + a routes.ts table,
matching the shared @branch/lambda-http package adopted repo-wide.

- handler.ts: now just `dispatch(event, { prefix: 'expenditures', routes })`.
- routes.ts: ordered Route[] table, ROUTES-START/END markers preserved
  around the entries. Route order matches the original if-chain, notably
  keeping /expenditures/upload-url before /expenditures/:id.
- controllers/expenditures.ts: one RouteHandler per route — validates
  input, calls the service layer, shapes the response. Same status codes,
  messages, and validation order as before.
- services/expenditures.ts: Kysely queries, S3 presigning, and
  receiptKeyFromUrl, unchanged in behavior, just relocated.
- Local json()/requireAuth() dropped in favor of the @branch/lambda-http
  exports (requireAuth's ADMIN gate on PATCH /expenditures/:id/status is
  now backed by the real @branch/lambda-auth checkAuthorization instead
  of the handler-local wrapper; identical logic).
- package.json: added @branch/lambda-http as a dependency;
  package-lock.json regenerated via `npm install --legacy-peer-deps`.
- test/expenditures.unit.test.ts: added a route-precedence regression
  test for GET /expenditures/upload-url vs /expenditures/:id.

No behavior change: same status codes, response shapes, S3 TTLs, and
content-type restriction as the previous if-chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adopts @branch/lambda-http: handler.ts is now a one-line dispatch()
call over routes.ts's ordered Route[] table, keeping the CLI
ROUTES-START/END markers around the table entries.

Moved, not changed:
- controllers/auth.ts: login, respond-challenge, refresh, me, logout
- controllers/register.ts: register, verify-email, resend-code
- controllers/password.ts: forgot-password, reset-password
- services/cognito.ts: cognitoClient, USER_POOL_CLIENT_ID/ID,
  CHALLENGE_SPECS, authResultResponse, challengeResponse,
  mapCognitoAuthError, validatePassword (byte-identical rules)

Local json()/parseBody() deleted in favor of @branch/lambda-http's
versions (identical implementations). Route order matches the
original if-chain exactly. No status codes, response bodies, message
strings, Cognito calls/params, password rules or auth gates changed.

Added @branch/lambda-http as a dependency and regenerated
package-lock.json; extended tsconfig include for controllers/services.
Adopts @branch/lambda-http's dispatch() in place of the hand-rolled
if/endsWith routing. handler.ts is now a one-liner; routes.ts holds the
ordered Route[] table (bracketed by the ROUTES-START/END markers, now
here instead of handler.ts).

Split the 769-line handler into:
- controllers/projects.ts: list, get, create, update, delete
- controllers/dashboard.ts: dashboard, overview
- controllers/members.ts: members, assignable-staff
- controllers/donors.ts: project donors
- controllers/expenditures.ts: project expenditures
- services/projects.ts: loadProjectAggregates, syncMemberships,
  findUnknownUserIds, isProjectActive, indexByProject, projectIdFrom,
  toIsoDate (the former bottom-of-file helpers)

Route order preserves the original if-chain exactly: /projects/dashboard
and /projects/assignable-staff are declared before /projects/:id since
dispatch is first-match-wins and both are same-length path shapes that
:id would otherwise swallow. auth.ts, db.ts, validation-utils.ts,
swagger-utils.ts and dev-server.ts are untouched.

Each handler now authenticates via createAuthGuard(authenticateRequest)
at the AUTHENTICATED level, replicating the old global
"authContext.isAuthenticated && authContext.user" gate exactly (same
401 body). Domain authorization (canAccessProject/canEditProject/
canCreateProject/canDeleteProject/canListAssignableStaff) is unchanged
and still runs per-route from the untouched auth.ts.

No behaviour change: same status codes, messages, validation order,
SQL and transaction boundaries. The known dashboard anti-pattern (bulk
expenditure select + JS bucketing) is moved verbatim, not fixed.

Added a route-precedence regression test asserting GET /projects/dashboard
reaches the dashboard controller rather than GET /projects/:id.

Adds @branch/lambda-http as a dependency (file: to shared/lambda-http)
and regenerates package-lock.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/lambda-deploy.yml
#	.github/workflows/lambda-tests.yml
# Conflicts:
#	apps/backend/lambdas/donors/handler.ts
# Conflicts:
#	apps/backend/lambdas/reports/handler.ts
# Conflicts:
#	apps/backend/lambdas/expenditures/handler.ts
# Conflicts:
#	apps/backend/lambdas/projects/handler.ts
The six lambdas moved from if-chain handlers to a declarative Route[]
table (@branch/lambda-http's dispatch()), but the generator and docs
still assumed the old shape. Bring them in line:

- lambda-cli.js: init-handler scaffolds a thin dispatch() handler.ts,
  an empty routes.ts, and db.ts; add-route inserts a routes.ts entry,
  scaffolds a controllers/<service>.ts stub, and updates openapi.yaml
  (auto-prefixing the path with the service name, {param} -> :param).
  list-routes/checkSimilarRoutes now read routes.ts instead of parsing
  if-conditions out of handler.ts.
- openapi.yaml (all six): normalized to full-prefixed paths matching
  each routes.ts table; added donors' missing GET /donations and
  expenditures' missing GET /upload-url + GET /{id}/receipt. Stripped
  redundant service-prefix suffixes from `servers:` entries so paths
  don't double-prefix through the dev-server's swagger UI.
- AGENTS.md (root, backend, lambdas): document @branch/lambda-http and
  the routes.ts/controllers/services layout; drop the stale if-chain
  handler pattern and normalizedPath codegen references.
- READMEs regenerated via `generate-readme`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nourshoreibah nourshoreibah added no-review The PR review bot won't run test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito labels Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🌿 ⏳ Creating preview environment… (logs)

@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment — failed ❌ to create. See the workflow logs.

nourshoreibah and others added 10 commits August 22, 2026 19:28
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Preview deploys were failing at esbuild with "Could not resolve
@branch/lambda-http" while lambda-tests and lambda-deploy were green.

Three workflows each encoded their own copy of "build the shared packages a
lambda depends on before packaging it", and adding @branch/lambda-http updated
only two of them. preview-env.yml still built lambda-auth alone, so the lambda's
npm ci installed a file: dependency whose dist had never been built and the
bundle could not resolve the import.

Replaces all of it with .github/actions/build-shared-packages, used by
lambda-tests (test + shared-http), lambda-deploy (build) and preview-env
(deploy). Build order lives in one place now: lambda-http declares lambda-auth
as file:../lambda-auth and compiles against its dist, so it goes second.

The next shared package added is the actual test of this: one edit instead of
four, with no fourth copy left to forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nversion

The lambda-readme workflow regenerates every README and pushes the result, and
it ran the old CLI against a converted lambda: extractRoutesFromHandler parses
if-conditions out of handler.ts, which is now four lines, so it found no routes
and auto-committed a README with all five of users' endpoints deleted.

- extractRoutesFromHandler now prefers a sibling routes.ts and falls back to the
  if-chain parse, so it reads converted and unconverted lambdas alike. That
  matters inside this stack, where only some lambdas have been converted at any
  given commit.
- collectRoutes lists health once, under the service prefix for a converted
  lambda and bare otherwise, instead of hardcoding /health and duplicating a
  spec entry that spells it the other way.
- users/openapi.yaml is normalized to match: paths carry the /users prefix and
  servers is the bare host. It previously contradicted itself -- servers ended
  in /users AND the /users path was prefixed, so Swagger built /users/users,
  while /{userId} had no prefix at all.

The fuller CLI rebuild lands in 8/8; this is the subset needed for the README
workflow to stop rewriting these files as each lambda converts.

expenditures/README.md picks up a POST /expenditures row: pre-existing drift on
main that the workflow would have auto-committed anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

README regenerated so the lambda-readme workflow has nothing to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

README regenerated so the lambda-readme workflow has nothing to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Test environment updated in place ✅ — Click here to open. updated for 3fcc039 · logs

@nourshoreibah nourshoreibah removed the test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito label Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment torn down 🧹 — the stack for this PR has been destroyed.

@nourshoreibah nourshoreibah added the test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito label Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🌿 ⏳ Creating preview environment… (logs)

@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment — ready ✅

Open: https://d3nmtjoh6ir9ym.cloudfront.net/pr-348/
API: https://z8ph8exr88.execute-api.us-east-2.amazonaws.com/prod

Shared RDS + Cognito (prod data); DB migrations are not applied here — if this PR adds a migration, endpoints using the new columns will fail until it merges. New commits update this environment in place — a note is posted here on each update. Remove the test-environment label or close the PR to tear it down.

@github-actions

Copy link
Copy Markdown
Contributor

🌿 Test environment updated in place ✅ — Click here to open. updated for 6d6bb3f · logs

@nourshoreibah
nourshoreibah marked this pull request as ready for review August 23, 2026 00:38
Base automatically changed from refactor/lambda-projects to main August 23, 2026 00:41
# Conflicts:
#	apps/backend/lambdas/tools/lambda-cli.js
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Test environment updated in place ✅ — Click here to open. updated for 6243910 · logs

@nourshoreibah
nourshoreibah merged commit 53ec5fb into main Aug 23, 2026
19 checks passed
@nourshoreibah
nourshoreibah deleted the refactor/lambda-cli branch August 23, 2026 03:05
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment torn down 🧹 — the stack for this PR has been destroyed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant