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 |
createHttpLambdaHandler — the request pipeline |
@cfxlabsinc/controllers/sentry |
initSentry — the lambda Sentry SDK, for instrument.ts |
@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
bundle 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.
src/index.ts must not re-export ./sentry.ts either. Shared libraries that
temporal-worker and the dashboards bundle import the root barrel, and
./sentry.ts imports the SDK only lambdas run. The same test enforces it.
Three things to know:
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, waitUntil }.
The first four names match Fastify's FastifyRequest, so most handler bodies
port unchanged. customerId and log are injected explicitly — no module
global holds them, deliberately: a Lambda environment serves one invocation
at a time, but the dev shell interleaves, so a global would be correct in
prod and wrong locally. request.waitUntil(promise) runs a side effect after
the response is sent: on Lambda the runtime holds the sandbox until it
settles, and in a test or a local run the handler waits for it before it
resolves. A rejection is logged as waitUntil work failed and reported to
Sentry; it never changes the response. The work still counts against the
function's timeout, and a replayed idempotent request does not run it again.
A service reaches the same thing with waitUntil from
@cfxlabsinc/result/wait-until, which the handler binds
per invocation on AsyncLocalStorage, so interleaved requests keep their own.reply.send. Responses are exact at
compile time: Reply.send refuses a payload key that no arm of the declared
response names, at any depth, and the runtime sends the payload as is. A test
checks a mapper's runtime output with responseSchemaOf from
@cfxlabsinc/controllers/testing.maintenanceService.register() is handled for you: createHttpLambdaHandler calls
it once per execution environment. You do not need a cold-start hook.
createSnsLambdaHandler({ serviceName, parse, consume, idempotency }) builds the
handler for an SNS subscription. It takes the records one at a time, in order:
JSON-decodes the message, hands it to parse, then calls
consume(event, { log, record, waitUntil }).
parse throws for a message that is not the
event, and so does a consume that fails. The invocation fails, SNS retries
it, and a persistent failure reaches the DLQ. Nothing after the failing record
in the batch is consumed. A message that is not JSON throws a SyntaxError
before parse sees it.parse returns undefined for an event the handler has no use for. It is
passed over before any claim, which is how a subscription with no filter
policy skips the events it does not route without writing a claim for each.idempotency is { key, claim, release }. The event is claimed before it
is consumed and skipped when the claim is already held. A consume that fails
releases the claim, so the retry is not swallowed. Without it every delivery is
consumed, redeliveries included.waitUntil work is not retried. It runs after the invocation has
succeeded, so SNS never sees it fail.withSentryCapture, as every exported handler is.All six SNS handlers are built on it, each with its own parse. The three that
dedupe build their claim with createDynamoEventClaims from
@cfxlabsinc/controllers/dynamodb. It is a subpath of its own so that only a
handler that claims bundles the DynamoDB client; dependency checks walk whole
projects, so every app that depends on this package still declares the client.
The API Gateway authorizer checks x-api-key and x-api-secret before the
function runs, so the handler authenticates nothing itself.
createHttpLambdaHandler takes the customer from
requestContext.authorizer.customerId and passes it to the route as
customerId.
authentication is "GATEWAY_AUTHORIZER" by default. An app whose routes
authenticate some other way (a webhook signature, an IP allowlist) passes
"NONE", and the handler never reads requestContext.probePaths.requestContext is caller-controlled on a direct lambda:InvokeFunction, so
"NONE", auth: false routes and probe paths resolve to the empty customer
id without reading it.cacheAuthTtlInSeconds), so a disabled customer keeps
working for that long.createInjector(handler, { customerId }), or
gatewayRequestContext(customerId) on a hand-built event. A local
serveHttp({ customerId }) run acts as that customer, since no authorizer
resolves one.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. resolveCustomerId tests
path. This is a security improvement./v1/thing/{id} alongside /v1/thing/{thingId}/children),
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/.