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
| Workspace | Package | Role |
|---|---|---|
apps/web | @adverta/web | Next.js UI; proxies /api/* → the API via a next.config rewrite. Never imports core. |
apps/mobile | @adverta/mobile | Expo / React Native; bearer access token in memory + rotating refresh in expo-secure-store. |
services/api | @adverta/api | Hono on :4000, /api/v1; owns all data access. tsx runs the TS directly — no build step. |
packages/core | @adverta/core | models / services / DB clients / middleware / metrics / bootstrap (Mongoose 8, ioredis). |
packages/contracts | @adverta/contracts | zod schemas + route table + IAM catalog — a zod-only leaf. |
packages/api-client | @adverta/api-client | one 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.