@cfxlabsinc/b2b-services
    Preparing search index...

    Module @cfxlabsinc/controllers

    controllers

    Common 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:

    1. 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.
    2. Handlers receive { 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.
    3. A handler answers only through 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 }).

    • A rejected message throws. 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.
    • Wrap the result in 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.
    • It fails loudly. An authenticated route that arrives with no customer id answers 500 and reports to Sentry. A missing id means the authorizer is not attached to that method, or its exempt paths have drifted from 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.
    • Revocation is slower than a per-request check. The gateway caches an Allow per credential pair (cacheAuthTtlInSeconds), so a disabled customer keeps working for that long.
    • Tests use 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.

    • 404 messages omit the query string. Fastify built the message from 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.
    • Validation error wording differs. AJV and TypeBox phrase individual errors[].message entries differently. The envelope ({code, message, errors}) and each entry's shape ({keyword, instancePath, schemaPath, params, message}) are identical.
    • Auth path exemptions match the path, not the URL. The Fastify auth plugin tested its ignore regex against request.url, so it exempted GET /v1/withdrawal?x=/health from authentication. resolveCustomerId tests path. This is a security improvement.
    • The router tolerates inconsistent parameter names at the same tree position (/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/.

    ApiRoutes
    HttpMethod
    MovementStatus
    OpenApiPaths
    Owner
    ReplayKind
    RequestReplay
    RouteSchema
    ServerLogger
    TProductLimits
    TUsage
    MOVEMENT_STATUSES
    TForbiddenError
    TIdempotencyKeyInProgressError
    TIdempotencyReusedError
    TInternalError
    TInvalidRequestError
    TNotFoundError
    TProductLimits
    TUnauthorizedError
    TUsage
    augmentRouteSchema
    createApiIdempotency
    describeError
    DiscriminatedUnion
    idempotencyTableName
    listQueryParamPattern
    logger
    ownerEntityId
    ownerOf
    parseListQueryParam
    parseOrderByQueryParam
    parsePublicKeyQueryParam
    parseTimestampQueryParam
    publicReasonCode
    TDecimalString
    TErrorResponse
    TMovementStatus
    TOwnerRequestProps
    TOwnerResponseProps
    TPositiveDecimalString
    TQuote
    TServiceError
    verifyHmacSignature