Architecture
Two deployables, one contract, and the seams that are enforced rather than agreed.
docs/ARCHITECTURE.md
Two deployables, one repo, one contract between them.
Why two deployables
The frontend and the backend scale, fail and deploy for different reasons. A publishing worker retrying LinkedIn's API has nothing to do with a marketing lead loading a chart. Splitting them means the API can be scaled, restarted or rewritten without touching the UI, and the UI can be served from an edge without dragging a database driver along. See DECISIONLOG.md D-001.
The seam is enforced, not merely documented:
| Boundary | Enforced by |
|---|---|
Web must not import Prisma, @verjson/api, or a DB driver | no-restricted-imports in apps/web/eslint.config.mjs |
| Web must not touch the filesystem outside the dashboard loader | same rule |
core/ must not import Hono | no-restricted-imports in apps/api/eslint.config.mjs |
| Both sides agree on the wire format | @verjson/contracts — one zod schema imported by both. A breaking change fails npm run typecheck in both workspaces. |
Repository layout
| Path | Holds | Notes |
|---|---|---|
apps/api/ | The backend service | Owns Prisma, migrations, auth, all business logic |
apps/api/src/main.ts | Process entry — binds the port, drains on SIGTERM | The only file that listens |
apps/api/src/app.ts | Route table — mounts each module under /api/v1 | A table of contents; never logic |
apps/api/src/http/ | Transport layer: error handler, auth + rate-limit middleware, body parsing | The only place Hono is allowed besides routes.ts |
apps/api/src/modules/<feature>/ | One folder per feature: routes.ts (thin) → domain → db | Where features get added |
apps/api/src/core/ | Framework-agnostic core: config · db · auth · audit · storage · ratelimit · serializers · errors | Knows nothing about HTTP. Callable from a cron worker or queue consumer unchanged. |
apps/api/prisma/ | Schema, migrations, seed | The single ORM touch-point |
apps/web/ | The frontend | No database access of any kind |
apps/web/src/app/(marketing)/ | The landing page | The internal front door |
apps/web/src/app/(app)/ | The authenticated product UI | Session-guarded by app-shell.tsx |
apps/web/src/app/(auth)/ | Login + signup | |
apps/web/src/app/(board)/dashboard/ | The build board | Deliberately outside the guard — internal, unauthenticated |
apps/web/src/components/ui/ | Design-system primitives | button · card · input · badge · table · modal · feedback |
apps/web/src/lib/api.ts | The only module that knows the API's address | axios + transparent 401 refresh |
packages/contracts/ | Zod schemas + inferred types shared by both apps | No React, no Prisma, no Hono. Ever. |
data/dashboard/ | The command-centre JSON (features, flows, architecture, tests, labs, meta) | Editable by hand or by an agent; read server-side |
docs/ | This folder. Every file is a table. | See README.md |
Request lifecycle
POST /api/v1/auth/login
→ cors() app.ts separate origins, so CORS is mandatory
→ limit("login", 10) http/middleware.ts per-IP fixed window, Postgres-backed
→ body(c, loginSchema) @verjson/contracts the same schema the web app validated with
→ domain logic modules/auth/routes.ts
→ prisma core/db.ts the single ORM touch-point
→ sUser(user) core/serializers.ts camelCase row → snake_case JSON
← 200 { user, tokens }
(any throw) → http/error-handler.ts → { detail } + status
No route handler contains a try/catch. Throw ApiError(status, message) and the single
app.onError hook shapes the response — which is why the error format cannot drift between
endpoints.
Conventions
| Rule | Detail |
|---|---|
| JSON is snake_case both ways | core/serializers.ts on the way out, @verjson/contracts on the way in. Prisma stays camelCase. |
Errors are always { detail } | A string for a single message, [{ msg }] for validation. Matches errorSchema. |
| Org-scope every query | A cross-tenant read must 404, never leak. requireRoles for RBAC, assertPlatformAdmin for cross-tenant admin. |
| Agency users are additionally project-scoped | Their token's org is not enough — a ProjectMember row is required, and it is enforced as a where clause so out-of-scope reads 404. See D-009. |
recordAudit for anything security-relevant | And for every agent-initiated action, with the actor key. |
Add new tables to apps/api/src/test/setup.ts | Or tests leak state between cases. |
process.env only in apps/api/src/core/config.ts | Frontend config must be NEXT_PUBLIC_* and is therefore public — never a secret. |
Backend modules
Present today; the rest land as features are built — see FEATURES.md.
| Module | Path | Purpose | Status |
|---|---|---|---|
| config | apps/api/src/core/config.ts | Every environment read, in one place | ✅ |
| db | apps/api/src/core/db.ts | Prisma singleton — the only ORM touch-point | ✅ |
| errors | apps/api/src/core/errors.ts | ApiError(status, message) | ✅ |
| auth | apps/api/src/core/auth.ts | bcrypt, JWT sign/verify, RBAC predicates | ✅ |
| audit | apps/api/src/core/audit.ts | Append-only trail | ✅ |
| crypto | apps/api/src/core/crypto.ts | AES-256-GCM at rest, per-purpose HKDF keys, one-way token hashing | ✅ |
| ratelimit | apps/api/src/core/ratelimit.ts | Postgres fixed-window, in-memory fallback | ✅ |
| storage | apps/api/src/core/storage.ts | S3/MinIO put · get · delete · presign | ✅ |
| serializers | apps/api/src/core/serializers.ts | Row → snake_case JSON | ✅ |
| http | apps/api/src/http/ | Error handler + middleware (auth, rate limit, body) | ✅ |
| auth routes | apps/api/src/modules/auth/routes.ts | signup · login · refresh · me | ✅ |
| health | apps/api/src/modules/health/routes.ts | Liveness/readiness with a DB check | ✅ |
| projects | apps/api/src/modules/projects/ | Projects, marketing mix, budgets, membership scoping | ✅ |
| oauth | apps/api/src/modules/oauth/ | Google + GitHub sign-in: signed state, verified-email linking, encrypted tokens | ✅ |
| billing | apps/api/src/modules/billing/ | Stripe subscriptions, checkout, portal, webhooks | 🔲 |
| growth | apps/api/src/modules/growth/ | SEMrush + Apollo, SEO and AEO audits | 🔲 |
| github | apps/api/src/modules/github/ | Repo connection; fixes opened as pull requests | 🔲 |
| calendar | apps/api/src/modules/calendar/ | Marketing calendar, scheduling, queues | 🔲 |
| composer | apps/api/src/modules/composer/ | Multi-channel post drafting | 🔲 |
| approvals | apps/api/src/modules/approvals/ | Approval workflow state machine | 🔲 |
| publisher | apps/api/src/modules/publisher/ | The outbound engine + retries | 🔲 |
| channels | apps/api/src/modules/channels/ | Provider adapters (one file per platform) | 🔲 |
| email-broadcasts | apps/api/src/modules/email-broadcasts/ | Per-project mailing list and the mail sent to it. csv + google-sheets (import), domain (audience resolution — the tenant-isolation gate), render (one function builds every outgoing message, so the preview, the test send and the dispatcher cannot disagree), attachments (bytes read from our own bucket by key, never fetched from a stored URL), send (multipart, per-recipient). The HTML sanitiser deliberately lives OUTSIDE this module, in @verjson/contracts, because the browser must run the identical one | ✅ |
| ads | apps/api/src/modules/ads/ | Google Ads + Meta Ads, benchmarks. adapters/ (one file per platform), publish (local tree → platform, resumable), sync (platform → local), pixels (E6.3 — the conversion pixels behind the ad-set builder's dropdown) | 🔲 |
| attribution | apps/api/src/modules/attribution/ | Tracker ingest (tracker-routes, public, tracker-key gated), the four attribution models (models, pure), and capi-forwarder — E6.3 server-side conversion forwarding to Meta. The forwarder is MUST-NOT-THROW: its caller is a customer's checkout page | ✅ |
| analytics | apps/api/src/modules/analytics/ | Metric ingestion + the KPI surface | 🔲 |
| agents | apps/api/src/modules/agents/ | Scoped keys, agent runs, MCP endpoint | 🔲 |
Frontend areas
| Area | Path | Purpose | Status |
|---|---|---|---|
| Landing | apps/web/src/app/(marketing)/page.tsx | The internal front door | ✅ |
| Dashboard | apps/web/src/app/(board)/dashboard/ | Command centre: features, flows, architecture, tests, labs, docs | ✅ |
| Style Lab | apps/web/src/app/style-guide/ | Live retheming; dev-only save back into globals.css | ✅ |
| Design system | apps/web/src/components/ui/ | Primitives + animation library | ✅ |
| API client | apps/web/src/lib/api.ts | axios + transparent 401 refresh | ✅ |
| Session | apps/web/src/lib/auth.tsx | Session context | ✅ |
| Projects | apps/web/src/app/(app)/projects/ | List, detail, marketing-mix picker, budget table | ✅ |
| Auth pages | apps/web/src/app/(auth)/ | Login, signup, social buttons, OAuth callback | ✅ |
| App shell | apps/web/src/app/(app)/app-shell.tsx | Session guard + product navigation | ✅ |
| Billing | apps/web/src/app/(app)/billing/ | Plan, invoices, payment method | 🔲 |
| Integrations | apps/web/src/app/(app)/integrations/ | Channels, ad accounts, SEMrush, Apollo, GitHub, WhatsApp | 🔲 |
| Calendar | apps/web/src/app/(app)/calendar/ | The marketing calendar | 🔲 |
| Composer | apps/web/src/app/(app)/composer/ | Drafting surface | 🔲 |
| KPIs | apps/web/src/app/(app)/kpis/ | One-place KPI surface | 🔲 |
Provider adapters
Every external platform implements one interface, so adding a channel is one file plus a registry entry — no change to the publisher, the inbox, or analytics.
ChannelProvider
getAuthUrl(redirectUri, state) → string
exchangeCode(code, redirectUri) → OAuthTokens
refreshToken(refreshToken) → OAuthTokens
getProfile(token) → AccountProfile
publish(token, content) → PublishResult
getPostMetrics(token, postId) → PostMetrics
getAccountMetrics(token, range) → AccountMetrics
getMessages(token, since) → InboxMessage[]
reply(token, messageId, text) → ReplyResult
revoke(token) → boolean
readonly platform: Platform
readonly maxCaptionLength: number
readonly supportedPostTypes: PostType[]
readonly rateLimits: RateLimitConfig
Ad platforms implement a second interface, AdsProvider, because their verbs are different —
budgets, bids, conversion actions, benchmarks — not posts.
Background work
Publishing, metric collection and token refresh are scheduled, not request-driven. They run as a
separate process from the same image as the API, calling the same core/ functions — which is
exactly why core/ may not import Hono.
| Job | Cadence |
|---|---|
| Publish scheduled posts | every 15s |
| Sync inbox messages | every 5 min per account |
| Collect post analytics | hourly (<48h old), then daily |
| Collect account + ad metrics | daily |
| Refresh OAuth tokens | hourly, for tokens expiring within 24h |
| Agent optimisation runs | daily, gated by the approval mode |
| Cleanup expired data | daily |
Deployment
| Environment | Shape |
|---|---|
| Local | docker compose up -d postgres minio + npm run dev / npm run dev:api |
| Local, production topology | docker compose --profile apps up -d — four containers |
| Production | Kubernetes: web Deployment, api Deployment, worker Deployment, managed Postgres, S3-compatible object store, Ingress with TLS |
Both images are multi-stage and run unprivileged. The API's readiness probe is its own
/api/v1/health, which reports 503 when Postgres is unreachable so the orchestrator routes
around an unhealthy pod rather than serving errors from it.
See DEPLOYMENT.md.