Skip to content

refactor(auth): declarative route table [6/8] - #346

Merged
nourshoreibah merged 35 commits into
mainfrom
refactor/lambda-auth
Aug 23, 2026
Merged

refactor(auth): declarative route table [6/8]#346
nourshoreibah merged 35 commits into
mainfrom
refactor/lambda-auth

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Stack 6/8. Base is #345, not main. Largest handler in the repo (846 lines → 4) and the most security-sensitive. Pure reorganization; no behaviour change.

⚠️ Conflicts with open PR #325 (TOTP MFA) — see the sequencing note at the bottom before merging either.

Route table

Method Pattern Controller Auth
POST /auth/register register.handleRegister unauthenticated by design (invitation-gated)
POST /auth/login auth.handleLogin unauthenticated by design
POST /auth/respond-challenge auth.handleRespondChallenge unauthenticated by design (session-token gated)
POST /auth/refresh auth.handleRefresh unauthenticated by design (refresh-token gated)
GET /auth/me auth.handleMe authenticated
POST /auth/verify-email register.handleVerifyEmail unauthenticated by design
POST /auth/resend-code register.handleResendCode unauthenticated by design
POST /auth/logout auth.handleLogout authenticated
POST /auth/forgot-password password.handleForgotPassword unauthenticated by design
POST /auth/reset-password password.handleResetPassword unauthenticated by design

Order preserved from the if chain. auth is the one service where most routes are deliberately public, so the table doubles as a readable audit of exactly which ones — which the old chain did not give you at a glance.

Gate confirmation

  • GET /auth/me — unchanged. Still calls authenticateRequest(event) and 401s on !isAuthenticated || !user before touching the DB.
  • POST /auth/logout — unchanged. Still requires the Authorization header locally, then relies on Cognito's own validation via GlobalSignOutCommand (401 on NotAuthorizedException). This route never called the shared authenticateRequest/requireAuth in the original either; that was preserved rather than "upgraded", since tightening it would be a behaviour change and belongs in its own PR.

Layout

  • controllers/auth.ts — login, respond-challenge, refresh, me, logout (session lifecycle).
  • controllers/register.ts — register, verify-email, resend-code.
  • controllers/password.ts — forgot-password, reset-password.
  • services/cognito.tscognitoClient, pool ids, CHALLENGE_SPECS, validatePassword, authResultResponse, challengeResponse, mapCognitoAuthError.
  • Local json() and parseBody() deleted in favour of the shared ones.
  • db.ts, auth.ts, swagger-utils.ts, dev-server.ts untouched.

handleLogout was inline in the original chain rather than a named function; it was extracted alongside login/refresh/me instead of getting a fourth controller file for one function. validatePassword moved byte-identical, comment intact — it mirrors the pool's password_policy in infrastructure/aws/cognito.tf.

Body parsing, deliberately inconsistent

handleLogin, handleRespondChallenge and handleRefresh use the shared parseBody, which is byte-identical to the deleted local one, so their 400 "Invalid JSON in request body" is unchanged.

handleRegister, handleVerifyEmail, handleResendCode, handleForgotPassword and handleResetPassword keep raw JSON.parse, because their malformed-JSON behaviour differs: a throw there falls through to a generic 500 rather than a local 400. Using parseBody would have changed those status codes. The underlying inconsistency (some routes 400, some 500 on bad JSON) is pre-existing and left alone — worth a follow-up, but not in a refactor.

Tests

  • npx jest test/auth.unit.test.ts test/auth.login.unit.test.ts66/66.
  • tsc --noEmit clean.
  • test/auth.e2e.test.ts — 3 of 4 fail with TypeError: fetch failed; it needs the dev-server on :3000 plus live Postgres, neither started (five sibling conversions were running concurrently). Not claimed to pass; CI gets its own Postgres per lambda.

Sequencing against PR #325

PR #325 (TOTP MFA enrollment and login flow) adds challenge/enrollment routes to handler.ts and extends CHALLENGE_SPECS. This PR gutted handler.ts to one line and moved CHALLENGE_SPECS into services/cognito.ts, so the two will conflict for real — not textually resolvable by rerunning a merge.

Whoever lands second re-applies their change against the new shape: route additions go in routes.ts, challenge-spec additions in services/cognito.ts, handler bodies in controllers/auth.ts. Recommendation: land #325 first, since it is feature work with a deadline shape and this is a refactor that can rebase cheaply.

Stack-wide note: unmatched paths now 404 instead of 401

Previously the top-level auth check ran before route matching, so an unauthenticated request to a nonexistent path got 401. dispatch returns 404 centrally without running auth, so an unauthenticated caller can now distinguish "route exists" (401) from "route doesn't" (404). Accepted deliberately — the route inventory is already in the repo's openapi.yaml files, and auth-before-routing is exactly what forces each lambda to hand-roll public-route exemptions. Least consequential here, where nine of ten routes are public by design.

Noticed, not fixed

auth/openapi.yaml still uses prefix-stripped paths (/login, /register) while the route table uses full-prefixed patterns (/auth/login). A spelling-convention mismatch, not a functional one — dispatch canonicalizes either way. PR 8/8 normalizes the specs.

🤖 Generated with Claude Code

nourshoreibah and others added 6 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.
@nourshoreibah nourshoreibah added the no-review The PR review bot won't run label Aug 22, 2026
nourshoreibah and others added 22 commits August 22, 2026 14:33
# 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
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>
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>
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 23, 2026 00:38
Base automatically changed from refactor/lambda-expenditures to main August 23, 2026 00:40
@nourshoreibah
nourshoreibah merged commit cd78877 into main Aug 23, 2026
20 checks passed
@nourshoreibah
nourshoreibah deleted the refactor/lambda-auth branch August 23, 2026 00:40
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant