This page documents the controller layer (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:
  1. 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 one service.* function, and shapes the response. All of the interesting behavior documented in Architecture lives in service.ts, not here.
  2. req.auth is populated by the requireAuth middleware (src/middleware/auth.ts) before any controller runs — { subjectType: "consumer" | "operator", subjectId: string }. Controllers that need it use the non-null assertion req.auth! because the route always applies requireAuth first; there’s no runtime check inside the controller itself; the ordering guarantee comes entirely from routes.ts.
  3. Ownership checks (assertOwnRecord-style: “does req.params.id match req.auth.subjectId?”) happen in the controller, not the service — the service functions take a bare userId and trust the caller already authorized it. This is a deliberate layering choice: services stay reusable/testable without an Express Request in scope, and the “is this actually your record” check stays visible at the HTTP boundary where it’s easiest to audit.
  4. Validated bodies/queries arrive pre-shaped. By the time a controller runs, validateBody/validateQuery middleware (src/middleware/validate.ts) has already parsed req.body/req.query through a zod schema and replaced them with the parsed (typed, defaulted, coerced) result — a controller reading req.body.phone is reading the zod-validated value, not the raw wire input.
  5. Default error path: an uncaught throw from a service function propagates up through express-async-errors to errorHandler (src/middleware/errorHandler.ts), which maps any AppError subclass to { error: errorCode, message } at err.statusCode. Most controllers rely on this entirely — no try/catch at all.
  6. Distinct-outcome exception: three controllers (identity-linking.verifyFace, verification.identify, verification.verifySelf) do catch AppError explicitly, to add an outcome field mirroring error in the body. This exists because those three endpoints are the ones the spec calls out as needing “distinct outcomes, not a single generic failure” — outcome gives clients a field to switch on that’s guaranteed present on every non-2xx response from those routes specifically, without overloading the meaning of error (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) — passes req.body.phone straight to authService.requestOtp. Always responds 200 { status: "sent" } regardless of whether the number is registered or was rate-limited server-side inside the service (rate-limit rejection happens in middleware/rateLimit.ts, applied in routes.ts before this controller runs — a 429 never reaches this function at all).
  • verifyOtp(req, res) — calls authService.verifyOtp(phone, code), which returns a SessionTokens object; the controller returns it verbatim as the response body. Doesn’t catch anything — OtpInvalidError/OtpTooManyAttemptsError (both AppError subclasses defined in modules/auth/service.ts) fall through to the default error handler.
  • operatorLogin(req, res) — calls authService.operatorLogin(operatorCode, password), which returns { tokens, mustResetPassword }. The controller flattens this: the response body has accessToken/refreshToken/expiresIn at the top level (not nested under tokens) plus mustResetPassword alongside them — this flattening is why operatorLogin’s response shape differs slightly from verifyOtp’s (which returns the raw SessionTokens object 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 applies requireAuth + requireSubjectType("operator") first) — kept because req.auth is typed optional (req.auth?: {...} in the Express.Request augmentation) and TypeScript can’t otherwise narrow it before the authService.operatorSetOwnPassword(req.auth.subjectId, ...) call three lines later.
  • refresh(req, res) / logout(req, res) — both operate on req.body.refreshToken alone, no req.auth at all (a refresh token is itself the credential — there’s no separate bearer-token requirement on these two routes). logout responds 204 with an empty body; refresh responds 200 with a fresh SessionTokens.

modules/identity-linking/controller.ts

Backs Identity Linking. Every export is a factory functionvalidate(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 reads req.body[bodyField(registryType)] (i.e. req.body.nin or req.body.bvn), calls service.validateIdentity(userId, registryType, number, req.ip), and returns the result verbatim. req.ip is threaded through for the linkedFromIp audit field the service writes on eventual link.
  • verifyFace(registryType) — returns a handler that pulls the uploaded file off req.file (cast through Request & { file?: Express.Multer.File } since Express’s base Request type doesn’t know about Multer’s augmentation without importing its namespace), then calls service.verifyFace(userId, registryType, validationToken, image?.buffer, reuseCapture, req.ip). Note image?.buffer is undefined when reuseCapture: true and 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 why outcome gets added to the error body here.
  • link(registryType) — reads req.body.linkToken, plus req.ip and req.headers["user-agent"] (passed as actorDevice — the closest thing to a device fingerprint this API captures, stored on the link row for the “coarse device hint” story-11 response later). Calls service.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 reading req.file. Also manually checks if (!image) throw new ValidationError("missing_field_image") before calling service.identify(operatorId, image.buffer)explicit-catch controller, adds outcome to any thrown AppError.
  • escalate(req, res) — reads req.params.referenceId, calls service.escalate(operatorId, referenceId), responds { status: "escalated" }. No explicit catch — a missing reference_id’s NotFoundError goes through the default error handler (no outcome field on this one, since it’s not one of the three distinct-outcome endpoints).
  • verifySelf(req, res) — same req.file extraction and missing-image guard as identify, 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 a 403, not a 404 — 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, calls service.getProfile(req.params.id), returns the profile object verbatim.
  • patchProfile(req, res) — asserts ownership, calls service.patchProfile(req.params.id, req.body). req.body at 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, calls service.deleteAccount(req.params.id, req.body.proofToken) — note this pulls proofToken out specifically rather than passing the whole body, since deleteAccountSchema only has the one field anyway.

modules/audit/controller.ts

  • getUserHistory(req, res) — inline ownership check (req.auth!.subjectId !== req.params.id, same pattern as profile’s assertOwnRecord but not factored out — a small duplication accepted for two call sites), reads page/pageSize off the already-validated req.query (cast via as unknown as {...} since Express’s Request.query type is ParsedQs, not the zod-parsed shape validateQuery actually put there), calls service.getUserHistory(id, page, pageSize).
  • exportUserHistory(req, res) — same ownership check, calls service.exportUserHistoryCsv(id), then does response-shaping a JSON controller never needs: sets Content-Type: text/csv and Content-Disposition: attachment; filename="history-{id}.csv" manually before res.send(csv) (a raw string, not res.json).
  • getOperatorSessions(req, res)no ownership check at all — this is intentional, not a gap: the route (modules/audit/routes.ts) gates this endpoint with requireAdministrator instead, 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) — reads referenceId and optional notes off req.body, calls service.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) — casts req.query to the filter shape (status/userId/from/to/page/pageSize) and passes it straight through to service.listDisputes. No ownership logic at all — gated by requireAdministrator at the route level, same pattern as getOperatorSessions.

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) — passes req.auth!.subjectId (the admin’s ID, for the audit log) and req.body (the new operator’s operatorCode/role/orgId) to service.createOperator, responds 201 with { operatorId, operatorCode, tempPassword }.
  • resetPassword(req, res)req.params.id is the target operator being reset (distinct from req.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 (void for 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 } off req.query (already Date | undefined post-validateQuery) and pass straight to the matching service function. Both endpoints are gated by requireAdministrator at the route level.
  • billing(req, res) — reads req.params.orgId, calls service.getBillingUsage(orgId). No validation on orgId itself — an unknown org ID resolves to the service’s CONTRACTED_TIERS.default tier rather than a 404, since there’s no Organization table to check membership against yet (see modules/analytics/service.ts’s static CONTRACTED_TIERS map).

modules/preferences/controller.ts

Backs Preferences. Same ownership pattern as profile, independently inlined rather than shared.
  • patchPreferences(req, res) — inline ownership check, calls service.patchPreferences(id, req.body), returns the updated preference row.
  • getPreferences(req, res) — inline ownership check, calls service.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 returns null (no UserPreference row exists yet, since one is only created lazily on first PATCH). This keeps GET idempotent-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 in routes.ts:
  • modules/system/routes.tsGET /health and GET /models. Both are pure aggregation (parallel checkDb()/checkRedis()/checkHttp() calls, or a single face-engine version fetch) with no service-layer business logic to separate out — a service.ts here would just be a pass-through, so it was skipped.
  • modules/settings/routes.tsGET /settings/attestation-required returns a hardcoded { attestationRequired: true, mutable: false }; PATCH /settings/attestation-required unconditionally throws ForbiddenError. Kept inline specifically because there’s no logic to have — routing directly to a throw is the implementation of “this setting is immutable,” not a shortcut around one (see Architecture and the System reference).