One shared API behind web and native
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:
| Workspace | Role |
|---|---|
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/contracts | zod schemas + route table + IAM catalog — zod-only leaf. |
packages/api-client | one typed ApiClient for web-cookie, SSR-cookie, and mobile-bearer callers. |
packages/core | models / 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.