src/modules/*/controller.ts), one level below the API Reference. Where that section describes the contract clients see, this page describes the implementation: exactly what each function reads off req, which service function it calls, and how it turns a result (or thrown error) into an HTTP response. Read this before modifying a controller or adding a new endpoint that follows the same pattern.
Shared conventions
Every controller in this codebase follows the same shape, so once you’ve read one you can predict the rest:- Thin by design. A controller never contains business logic, a database call, or a client-adapter call — it only reads
req(params/query/body/file), calls exactly oneservice.*function, and shapes the response. All of the interesting behavior documented in Architecture lives inservice.ts, not here. req.authis populated by therequireAuthmiddleware (src/middleware/auth.ts) before any controller runs —{ subjectType: "consumer" | "operator", subjectId: string }. Controllers that need it use the non-null assertionreq.auth!because the route always appliesrequireAuthfirst; there’s no runtime check inside the controller itself; the ordering guarantee comes entirely fromroutes.ts.- Ownership checks (
assertOwnRecord-style: “doesreq.params.idmatchreq.auth.subjectId?”) happen in the controller, not the service — the service functions take a bareuserIdand trust the caller already authorized it. This is a deliberate layering choice: services stay reusable/testable without an ExpressRequestin scope, and the “is this actually your record” check stays visible at the HTTP boundary where it’s easiest to audit. - Validated bodies/queries arrive pre-shaped. By the time a controller runs,
validateBody/validateQuerymiddleware (src/middleware/validate.ts) has already parsedreq.body/req.querythrough a zod schema and replaced them with the parsed (typed, defaulted, coerced) result — a controller readingreq.body.phoneis reading the zod-validated value, not the raw wire input. - Default error path: an uncaught throw from a service function propagates up through
express-async-errorstoerrorHandler(src/middleware/errorHandler.ts), which maps anyAppErrorsubclass to{ error: errorCode, message }aterr.statusCode. Most controllers rely on this entirely — notry/catchat all. - Distinct-outcome exception: three controllers (
identity-linking.verifyFace,verification.identify,verification.verifySelf) do catchAppErrorexplicitly, to add anoutcomefield mirroringerrorin the body. This exists because those three endpoints are the ones the spec calls out as needing “distinct outcomes, not a single generic failure” —outcomegives clients a field to switch on that’s guaranteed present on every non-2xx response from those routes specifically, without overloading the meaning oferror(which every endpoint already has via the default error handler).
modules/auth/controller.ts
Backs Auth. No ownership checks needed — every function operates on the caller’s own session or a phone number/code pair, never someone else’s record.
requestOtp(req, res)— passesreq.body.phonestraight toauthService.requestOtp. Always responds200 { status: "sent" }regardless of whether the number is registered or was rate-limited server-side inside the service (rate-limit rejection happens inmiddleware/rateLimit.ts, applied inroutes.tsbefore this controller runs — a 429 never reaches this function at all).verifyOtp(req, res)— callsauthService.verifyOtp(phone, code), which returns aSessionTokensobject; the controller returns it verbatim as the response body. Doesn’t catch anything —OtpInvalidError/OtpTooManyAttemptsError(bothAppErrorsubclasses defined inmodules/auth/service.ts) fall through to the default error handler.operatorLogin(req, res)— callsauthService.operatorLogin(operatorCode, password), which returns{ tokens, mustResetPassword }. The controller flattens this: the response body hasaccessToken/refreshToken/expiresInat the top level (not nested undertokens) plusmustResetPasswordalongside them — this flattening is whyoperatorLogin’s response shape differs slightly fromverifyOtp’s (which returns the rawSessionTokensobject unflattened, since there’s no extra flag to attach).operatorSetPassword(req, res)— the one function with a manual guard:if (!req.auth) throw new UnauthorizedError(). This is defensive, not load-bearing (the route always appliesrequireAuth+requireSubjectType("operator")first) — kept becausereq.authis typed optional (req.auth?: {...}in theExpress.Requestaugmentation) and TypeScript can’t otherwise narrow it before theauthService.operatorSetOwnPassword(req.auth.subjectId, ...)call three lines later.refresh(req, res)/logout(req, res)— both operate onreq.body.refreshTokenalone, noreq.authat all (a refresh token is itself the credential — there’s no separate bearer-token requirement on these two routes).logoutresponds204with an empty body;refreshresponds200with a freshSessionTokens.
modules/identity-linking/controller.ts
Backs Identity Linking. Every export is a factory function —
validate(registryType), not validate(req, res) directly — because the NIN and BVN routes share identical controller logic and only differ in which body field/service-layer registry type they read. routes.ts calls controller.validate("NIN") and controller.validate("BVN") to produce the two actual Express handlers.bodyField(registryType)— a private helper mapping"NIN" → "nin","BVN" → "bvn"; used only to know which body key holds the number.validate(registryType)— returns a handler that readsreq.body[bodyField(registryType)](i.e.req.body.ninorreq.body.bvn), callsservice.validateIdentity(userId, registryType, number, req.ip), and returns the result verbatim.req.ipis threaded through for thelinkedFromIpaudit field the service writes on eventual link.verifyFace(registryType)— returns a handler that pulls the uploaded file offreq.file(cast throughRequest & { file?: Express.Multer.File }since Express’s baseRequesttype doesn’t know about Multer’s augmentation without importing its namespace), then callsservice.verifyFace(userId, registryType, validationToken, image?.buffer, reuseCapture, req.ip). Noteimage?.bufferisundefinedwhenreuseCapture: trueand no file was uploaded — the service function is the one that decides whether that’s valid (it’s fine if reusing a cached capture, an error otherwise). This is one of the three explicit-catch controllers — see the shared-conventions note above for whyoutcomegets added to the error body here.link(registryType)— readsreq.body.linkToken, plusreq.ipandreq.headers["user-agent"](passed asactorDevice— the closest thing to a device fingerprint this API captures, stored on the link row for the “coarse device hint” story-11 response later). Callsservice.finalizeLink(...)and returns{ status: "linked", linkedAt }verbatim.
modules/verification/controller.ts
Backs Verification. Unlike identity-linking, these are plain handlers (not factories) —
/identify and /verify/self have no NIN/BVN split to parameterize over.identify(req, res)— the only controller in the codebase with business-rule-shaped validation inside it rather than in a zod schema:if (req.body.attestation !== "true") throw new ValidationError(...). This is deliberate, not an oversight — attestation must reject both a missing field and an explicit"false", which a plain zod.literal("true")schema would already do, but the spec’s AC (“rejected before any dependency call”) is easiest to guarantee by keeping the check as the very first line of the controller, ahead of even readingreq.file. Also manually checksif (!image) throw new ValidationError("missing_field_image")before callingservice.identify(operatorId, image.buffer)— explicit-catch controller, addsoutcometo any thrownAppError.escalate(req, res)— readsreq.params.referenceId, callsservice.escalate(operatorId, referenceId), responds{ status: "escalated" }. No explicit catch — a missing reference_id’sNotFoundErrorgoes through the default error handler (nooutcomefield on this one, since it’s not one of the three distinct-outcome endpoints).verifySelf(req, res)— samereq.fileextraction and missing-image guard asidentify, but no attestation check (that’s an/identify-only, merchant-desk-only concept). Explicit-catch controller.
modules/profile/controller.ts
Backs Profile. Introduces the
assertOwnRecord pattern used again (independently, not shared code) in audit and preferences controllers.assertOwnRecord(req)— private helper:if (req.auth!.subjectId !== req.params.id) throw new ForbiddenError(...). Called as the first line of all three exported functions below. Note this is a403, not a404— the controller doesn’t try to hide whether the target ID exists, since these routes are always “your own ID” by contract and a mismatch is unambiguously an authorization failure, not an information-disclosure risk.getProfile(req, res)— asserts ownership, callsservice.getProfile(req.params.id), returns the profile object verbatim.patchProfile(req, res)— asserts ownership, callsservice.patchProfile(req.params.id, req.body).req.bodyat this point has already passed the.strict()zod schema (patchProfileSchema) that rejects unknown fields — the controller itself does no field-level filtering, it trusts the schema did that job.deleteAccount(req, res)— asserts ownership, callsservice.deleteAccount(req.params.id, req.body.proofToken)— note this pullsproofTokenout specifically rather than passing the whole body, sincedeleteAccountSchemaonly has the one field anyway.
modules/audit/controller.ts
Backs Audit & History.
getUserHistory(req, res)— inline ownership check (req.auth!.subjectId !== req.params.id, same pattern asprofile’sassertOwnRecordbut not factored out — a small duplication accepted for two call sites), readspage/pageSizeoff the already-validatedreq.query(cast viaas unknown as {...}since Express’sRequest.querytype isParsedQs, not the zod-parsed shapevalidateQueryactually put there), callsservice.getUserHistory(id, page, pageSize).exportUserHistory(req, res)— same ownership check, callsservice.exportUserHistoryCsv(id), then does response-shaping a JSON controller never needs: setsContent-Type: text/csvandContent-Disposition: attachment; filename="history-{id}.csv"manually beforeres.send(csv)(a raw string, notres.json).getOperatorSessions(req, res)— no ownership check at all — this is intentional, not a gap: the route (modules/audit/routes.ts) gates this endpoint withrequireAdministratorinstead, since it’s branch-admin tooling for auditing other operators, not a self-service endpoint. The authorization model here is role-based (must be an admin) rather than identity-based (must be you).
modules/disputes/controller.ts
Backs Disputes.
createDispute(req, res)— readsreferenceIdand optionalnotesoffreq.body, callsservice.createDispute(userId, referenceId, notes). No ownership check in the controller — the service function does that check instead (log.matchedUserId === userId || ...), because “do you own this SearchLog entry” requires a DB read the controller shouldn’t duplicate. This is the one controller in the codebase where the ownership check is deliberately pushed down into the service layer rather than kept at the HTTP boundary — worth noting as the exception to convention #3 above, and why: the check here isn’t “does a URL param match your token” (cheap, no DB needed) but “does a referenced row belong to you” (needs a query anyway, so the service does it once).listDisputes(req, res)— castsreq.queryto the filter shape (status/userId/from/to/page/pageSize) and passes it straight through toservice.listDisputes. No ownership logic at all — gated byrequireAdministratorat the route level, same pattern asgetOperatorSessions.
modules/operators/controller.ts
Backs Operators. Every route this backs is already gated by
requireAdministrator + requireOperatorPasswordCurrent in routes.ts, so none of these functions re-check authorization — req.auth!.subjectId is only used as the actor for audit-trail purposes, not as an authorization input.createOperator(req, res)— passesreq.auth!.subjectId(the admin’s ID, for the audit log) andreq.body(the new operator’soperatorCode/role/orgId) toservice.createOperator, responds201with{ operatorId, operatorCode, tempPassword }.resetPassword(req, res)—req.params.idis the target operator being reset (distinct fromreq.auth!.subjectId, the admin doing the resetting) — responds{ tempPassword }.updateOperator(req, res)/deactivateOperator(req, res)— same actor/target distinction; both respond with a bare{ status: "..." }rather than echoing the updated resource, since the service function’s return value (voidfor both) isn’t meant to be read back — callers re-fetch via other endpoints if they need the current state.
modules/analytics/controller.ts
Backs Analytics & Billing. The thinnest controllers in the codebase — no ownership logic, no error handling, just a query-param pass-through.
accuracy(req, res)/volume(req, res)— both destructure{ from, to }offreq.query(alreadyDate | undefinedpost-validateQuery) and pass straight to the matching service function. Both endpoints are gated byrequireAdministratorat the route level.billing(req, res)— readsreq.params.orgId, callsservice.getBillingUsage(orgId). No validation onorgIditself — an unknown org ID resolves to the service’sCONTRACTED_TIERS.defaulttier rather than a 404, since there’s noOrganizationtable to check membership against yet (seemodules/analytics/service.ts’s staticCONTRACTED_TIERSmap).
modules/preferences/controller.ts
Backs Preferences. Same ownership pattern as
profile, independently inlined rather than shared.patchPreferences(req, res)— inline ownership check, callsservice.patchPreferences(id, req.body), returns the updated preference row.getPreferences(req, res)— inline ownership check, callsservice.getPreferences(id), and — the one bit of controller-level fallback logic in this module — substitutes a default shape ({ userId, language: "en", notifications: {} }) when the service returnsnull(noUserPreferencerow exists yet, since one is only created lazily on firstPATCH). This keepsGETidempotent-feeling for a user who’s never set a preference, without the service needing to know about response defaults.
System and Settings: no controller.ts
Two modules skip the controller layer entirely and inline their (very short) handlers directly inroutes.ts:
modules/system/routes.ts—GET /healthandGET /models. Both are pure aggregation (parallelcheckDb()/checkRedis()/checkHttp()calls, or a singleface-engineversion fetch) with no service-layer business logic to separate out — aservice.tshere would just be a pass-through, so it was skipped.modules/settings/routes.ts—GET /settings/attestation-requiredreturns a hardcoded{ attestationRequired: true, mutable: false };PATCH /settings/attestation-requiredunconditionally throwsForbiddenError. Kept inline specifically because there’s no logic to have — routing directly to athrowis the implementation of “this setting is immutable,” not a shortcut around one (see Architecture and the System reference).