AZ
Back to constellation
CommercePublished

Adverta

A Nigerian advertising/marketplace platform — one shared Hono API behind a Next.js web app and an Expo mobile app, with a billing-aware campaign model and AWS-style per-request IAM.

TurborepoHono 4Next.js 16Expo / React NativeTypeScriptMongoDB / Mongoose 8Redis (ioredis)zod contractsPrometheusPaystack
Read the write-up

Overview

What it is

Adverta is "Nigeria's business advertising platform — list free, boost from ₦500/day, chat with buyers in-app": a self-serve marketplace + campaign builder + advertiser dashboard + agency white-label + Trust-&-Safety ops, delivered as three consumers over one shared HTTP API. It's a Yarn-workspaces Turborepo, and the backend is a deliberate "carbon-clone" of the Golden Bite arch: per-operation *DB model fns, per-op service files with a Redis read-through cache, Prometheus histograms, a {code,message,data} envelope, and AWS-style IAM.

An honest correction to the internal brief: the backend is Hono + Mongoose 8 / MongoDB, not Fastify/Prisma, and "per-operation database/service" means code structure plus application-level IAM, not distinct database credentials.

The shared, typed API contract

WorkspacePackageRole
apps/web@adverta/webNext.js UI; proxies /api/* → the API via a next.config rewrite. Never imports core.
apps/mobile@adverta/mobileExpo / React Native; bearer access token in memory + rotating refresh in expo-secure-store.
services/api@adverta/apiHono on :4000, /api/v1; owns all data access. tsx runs the TS directly — no build step.
packages/core@adverta/coremodels / services / DB clients / middleware / metrics / bootstrap (Mongoose 8, ioredis).
packages/contracts@adverta/contractszod schemas + route table + IAM catalog — a zod-only leaf.
packages/api-client@adverta/api-clientone transport-agnostic ApiClient for all three callers.

The purist boundary is the point: neither web nor mobile imports core — both speak to services/api over HTTP through @adverta/api-client, sharing the @adverta/contracts schemas, and every package is consumed as raw TypeScript via exports maps with no build step. So a contract change breaks the compile of both apps in the same commit (yarn ts.check), not as a runtime surprise — there is no "the mobile app is two versions behind the endpoint" class of bug, because the endpoint's request schema and the client's input type are the same Zod object. The mobile client de-dupes concurrent 401s via a single refreshInFlight promise so its single-use rotating refresh token is spent exactly once under a burst. Backing services are hard requirements wired in bootstrap() (no in-memory fallback), with /healthz (liveness) and /readyz (Mongo ping + Redis PING, 503 when down) probes.

Billing-aware campaigns and money safety

ICampaign carries format (boost|sponsored|blast|banner), status, a budget, spend, targeting, and metrics — visibility modelled as a paid, time-bound thing. The load-bearing move is an atomic budget draw: spend + a lead are booked only while spentNaira + amount <= totalBudgetNaira, enforced by a $expr in the query filter so check-and-book is one indivisible act with no concurrent overspend:

const result = await Campaign.findOneAndUpdate(
  { _id: id, $expr: { $lte: [{ $add: ["$spentNaira", amountNaira] }, "$totalBudgetNaira"] } },
  { $inc: { spentNaira: amountNaira, leads: 1 } },
  { returnDocument: "after" },
).lean<ICampaign>();

Attribution is derived server-side and ignores any client campaignId; billing fires only on the trusted startConversation path, keyed on a billingActorId to defeat Sybil drain; totals are always recomputed, never trusted. The Paystack webhook verifies the signature first, moves the ledger only on charge.success with an exact amount match, and is idempotent via unique provider/charge refs — non-2xx on failure so Paystack retries.

Agency white-label and per-request IAM

The multi-tenant agency layer (tiered agencies with a white-label brandColor, joined to client businesses via agencyClients) enforces tenancy in withPermission: resolveScopedAgencyId takes the agency id from the session, never the request, and holding a feature permission is not enough to reach another tenant — a platform operator (metrics:read) is the only principal who may inspect any tenant via an explicit query. IAM itself is AWS-flavoured with no role layer: a flat resource:action permission catalog, policies of Allow/Deny statements where an explicit Deny always wins, and groups that bundle policies:

export function compileStatements(statements: IPolicyStatement[]): TPermission[] {
  const allow = new Set<TPermission>();
  const deny = new Set<TPermission>();
  for (const statement of statements) {
    const target = statement.effect === "Deny" ? deny : allow;
    for (const perm of expandActions(statement.actions)) target.add(perm);
  }
  for (const perm of Array.from(deny)) allow.delete(perm);
  return Array.from(allow).sort();
}

A user's effective set is re-resolved from the database on every request (Redis-cached for 30s; an inactive account resolves to the empty set = deny all), so an admin's revocation or deactivation bites within seconds, not at next sign-in.

Redis, metrics & testing posture

Redis is an ioredis singleton on global.__advertaRedis (HMR-safe) that pings on boot and fails loud, with glob delete via non-blocking SCAN; read services cache under services:<domain>:<method>:<params> and never cache empty results. Prometheus exposes two histograms — http_request_duration_seconds (observed in withApiHandler) and database_request_duration_seconds (observed in every *DB fn) — at an anonymous /api/v1/metrics. Vitest targets a throwaway db whose globalSetup refuses any db not prefixed adverta_vitest; Playwright covers e2e; CI runs ts.check, Biome, core tests, and both Docker builds (asserting non-root images with no baked-in .env). A prod boot guard exits before binding if JWT_SECRET is missing or under 32 chars, delivery credentials fail closed with a 503 rather than lie, and Pusher private channels are signed only after the server verifies participation.

Related write-ups