controllersCommon utilities for API controllers and their HTTP runtimes.
Please do not place any functional/business common stuff in here. This is purely for non-functional or cross-functional components.
Every app serves the same declarative route tables (ApiRoutes objects in each
*-api package's src/interface/) through the Lambda runtime:
| Import | Runtime |
|---|---|
@cfxlabsinc/controllers |
Shared vocabulary — error schemas, route types, TypeBox helpers |
@cfxlabsinc/controllers/lambda |
createLambdaHandler — the request pipeline |
@cfxlabsinc/controllers/http |
serveHttp — node:http dev shell over the same handler |
@cfxlabsinc/controllers/openapi |
buildOpenApiDocument — build-time spec assembly |
The runtime replaced Fastify + @fastify/aws-lambda + AJV + fast-json-stringify
with a router over event.path, TypeBox's JIT Validator, and a small reply
shim. It cut roughly 150ms off every invocation on the first apps that moved.
Fastify is gone from the repo: no package depends on it, and the spec generator
assembles the OpenAPI document itself rather than reading it out of
@fastify/swagger.
src/index.ts must never re-export ./lambda, ./http or ./openapi, and
nothing under src/lambda/ may import Fastify or src/http/. Those are separate
esbuild entry points, which is what keeps build-time and dev-only code out of
every production Lambda bundle. test/packageBoundaries.test.ts enforces all
three; a type-only import type { FastifyReply } trips it too, because it
signals the wrong dependency direction even though the build erases it.
Three things bite, and none of them fail loudly:
logger() is not request-scoped. It returns a console-backed logger with
no invocation keys, kept for event consumers and service internals that are
handed no logger of their own. Anything reached from a route should take
request.log — the per-request Powertools child logger, which carries the
request id and whatever appendKeys put on it.{ params, query, headers, body, customerId, log, event }.
The first four names match Fastify's FastifyRequest, so most handler bodies
port unchanged. customerId and log are injected explicitly — there is no
ambient context, deliberately: a Lambda environment serves one invocation at
a time, but the dev shell interleaves, so ambient state would be correct in
prod and wrong locally.augmentRouteSchema injects default: TInternalError on every route, so a
payload returned at an undeclared status is cleaned against that schema and
stripped. Returning 201 while declaring only 200 yields an empty body.cleanResponse mutates its payload and returns the same reference —
Validator.Clean() works in place. Do not read or log a response payload
after it has been serialised.maintenanceService.register() is handled for you: createLambdaHandler calls
it once per execution environment. You do not need a cold-start hook.
These were verified request-by-request against the Fastify stack while both existed. They are intentional; do not "fix" them into parity.
request.url; the Lambda path uses event.path. GET /v1/x?a=1 now yields
Cannot route to GET /v1/x. Echoing a client-controlled query string into an
error body is an unnecessary reflection surface.errors[].message entries differently. The envelope
({code, message, errors}) and each entry's shape
({keyword, instancePath, schemaPath, params, message}) are identical.ignore regex against request.url, so it exempted
GET /v1/withdrawal?x=/health from authentication. authenticate() tests
path. This is a security improvement./v1/identity/{id} alongside /v1/identity/{identityId}/documents),
which find-my-way rejects at registration. Parameters are stored per route.GET and DELETE validation failures used to return INTERNAL.
augmentRouteSchema injected a 400 schema only for POST/PATCH/PUT, so a
GET/DELETE 400 body fell through to the injected default: TInternalError.
Because that schema declares code/message as T.Literal,
fast-json-stringify substitutes the literals rather than validating against
them — so the response was {"code":"INTERNAL", …} with the field-level
errors[] discarded, under a 400 status. It affected the 31 of 41 GET/DELETE
operations that did not declare their own 400 (organization-api 17,
identity-api 11, deposit-api 3). A base 400 is now injected for every method.
The Idempotency-Key header schema enforces nothing. augmentRouteSchema
injects the key capitalised while the runtime lowercases all headers, so the
schema property never matches — true of the Fastify path before it as well.
Behaviour is unaffected because idempotency.ts does its own case-insensitive
lookup. A real fix has to keep canonical casing in the published OpenAPI spec
while matching case-insensitively at runtime, so it spans both the runtime and
openapi/.