AZ
All writing
3 min read

One shared API behind web and native

turborepohononextjsreact-nativemonorepomarketplace
Part of the projectAdvertaA 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.View project

Adverta is a Nigeria-focused advertising and marketplace product — free listings, paid boosts, in-app chat, a campaign builder, and an agency white-label mode — fronted by two clients, a Next.js web app and an Expo/React-Native mobile app, speaking to exactly one backend. It's a Turborepo, and the shared contract is the whole point.

One contract, consumed as TypeScript

The backend is a Hono API over Mongoose 8 / MongoDB (not Fastify/Prisma), and neither client imports its internals:

WorkspaceRole
apps/web (@adverta/web)Next.js UI; proxies /api/* to the API. Never imports core.
apps/mobile (@adverta/mobile)Expo; bearer token in memory + rotating refresh in secure-store.
services/api (@adverta/api)Hono :4000 /api/v1; owns all data. tsx, no build step.
packages/contractszod schemas + route table + IAM catalog — zod-only leaf.
packages/api-clientone typed ApiClient for web-cookie, SSR-cookie, and mobile-bearer callers.
packages/coremodels / services / DB / middleware / metrics.

Both apps talk to the API over HTTP through @adverta/api-client, sharing the @adverta/contracts Zod schemas — and packages are 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. 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 (createCampaignSchema is imported by both the API route and the client). The mobile client's 401 auto-refresh is de-duped via a single refreshInFlight promise, so the single-use rotating refresh token is spent exactly once even under a burst of concurrent 401s.

Money that can't be double-spent

Boosts are a billing-aware model: a campaign has a budget, a spend, and metrics, and visibility is a paid, time-bound thing. The load-bearing detail is that a budget draw is a single atomic act — the ceiling lives in the query filter, so check-and-book can't race into an 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-sent campaignId; billing fires only on the trusted conversation-start path, keyed on a billing actor id to defeat Sybil drain; and totals are always recomputed, never trusted.

IAM with no roles, and Deny that wins

Authorization 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.

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);

The property that makes it trustworthy is when it runs: a user's effective set is re-resolved from the database on every request, not read from the JWT. The token only identifies the caller; the permissions are recomputed (Redis-cached for 30 seconds; an inactive account resolves to the empty set = deny all), so a revocation or deactivation bites within seconds:

const cached = await redisRetrieveKeyString<TPermission[]>(key);
if (cached) return cached;
const user = await getUserById({ id: userId });
if (!user) return []; // inactive/missing → empty = deny all
const effective = await resolveEffectiveForUser({ user });
await redisUpdateKeyString<TPermission[]>(key, effective, true, CACHE_TTL_SECONDS);

Tenancy from the session, never the request

Agency white-label rides on top. resolveScopedAgencyId takes the agency id from the session, and a caller who merely holds a feature permission still can't reach another tenant — only a platform operator (metrics:read) may inspect a requested tenant via an explicit query:

export async function sessionCanAccessAgency(session, agencyId) {
  if (session.agencyId && session.agencyId === agencyId) return true;
  return sessionHasPermissions(session, [Permission.MetricsRead]);
}

So holding clients:write is enough to manage your agency's clients and no one else's. One honest caveat carried into the writing: "per-operation database/service" describes code structure plus application-level IAM — one *DB fn and one service file per operation — not distinct database credentials.

Back to the projectAdvertaSee the full case study and related write-ups.View project