Developer docs
System

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:

BoundaryEnforced by
Web must not import Prisma, @verjson/api, or a DB driverno-restricted-imports in apps/web/eslint.config.mjs
Web must not touch the filesystem outside the dashboard loadersame rule
core/ must not import Honono-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

PathHoldsNotes
apps/api/The backend serviceOwns Prisma, migrations, auth, all business logic
apps/api/src/main.tsProcess entry — binds the port, drains on SIGTERMThe only file that listens
apps/api/src/app.tsRoute table — mounts each module under /api/v1A table of contents; never logic
apps/api/src/http/Transport layer: error handler, auth + rate-limit middleware, body parsingThe only place Hono is allowed besides routes.ts
apps/api/src/modules/<feature>/One folder per feature: routes.ts (thin) → domain → dbWhere features get added
apps/api/src/core/Framework-agnostic core: config · db · auth · audit · storage · ratelimit · serializers · errorsKnows nothing about HTTP. Callable from a cron worker or queue consumer unchanged.
apps/api/prisma/Schema, migrations, seedThe single ORM touch-point
apps/web/The frontendNo database access of any kind
apps/web/src/app/(marketing)/The landing pageThe internal front door
apps/web/src/app/(app)/The authenticated product UISession-guarded by app-shell.tsx
apps/web/src/app/(auth)/Login + signup
apps/web/src/app/(board)/dashboard/The build boardDeliberately outside the guard — internal, unauthenticated
apps/web/src/components/ui/Design-system primitivesbutton · card · input · badge · table · modal · feedback
apps/web/src/lib/api.tsThe only module that knows the API's addressaxios + transparent 401 refresh
packages/contracts/Zod schemas + inferred types shared by both appsNo 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

RuleDetail
JSON is snake_case both wayscore/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 queryA cross-tenant read must 404, never leak. requireRoles for RBAC, assertPlatformAdmin for cross-tenant admin.
Agency users are additionally project-scopedTheir 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-relevantAnd for every agent-initiated action, with the actor key.
Add new tables to apps/api/src/test/setup.tsOr tests leak state between cases.
process.env only in apps/api/src/core/config.tsFrontend 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.

ModulePathPurposeStatus
configapps/api/src/core/config.tsEvery environment read, in one place✅
dbapps/api/src/core/db.tsPrisma singleton — the only ORM touch-point✅
errorsapps/api/src/core/errors.tsApiError(status, message)✅
authapps/api/src/core/auth.tsbcrypt, JWT sign/verify, RBAC predicates✅
auditapps/api/src/core/audit.tsAppend-only trail✅
cryptoapps/api/src/core/crypto.tsAES-256-GCM at rest, per-purpose HKDF keys, one-way token hashing✅
ratelimitapps/api/src/core/ratelimit.tsPostgres fixed-window, in-memory fallback✅
storageapps/api/src/core/storage.tsS3/MinIO put · get · delete · presign✅
serializersapps/api/src/core/serializers.tsRow → snake_case JSON✅
httpapps/api/src/http/Error handler + middleware (auth, rate limit, body)✅
auth routesapps/api/src/modules/auth/routes.tssignup · login · refresh · me✅
healthapps/api/src/modules/health/routes.tsLiveness/readiness with a DB check✅
projectsapps/api/src/modules/projects/Projects, marketing mix, budgets, membership scoping✅
oauthapps/api/src/modules/oauth/Google + GitHub sign-in: signed state, verified-email linking, encrypted tokens✅
billingapps/api/src/modules/billing/Stripe subscriptions, checkout, portal, webhooks🔲
growthapps/api/src/modules/growth/SEMrush + Apollo, SEO and AEO audits🔲
githubapps/api/src/modules/github/Repo connection; fixes opened as pull requests🔲
calendarapps/api/src/modules/calendar/Marketing calendar, scheduling, queues🔲
composerapps/api/src/modules/composer/Multi-channel post drafting🔲
approvalsapps/api/src/modules/approvals/Approval workflow state machine🔲
publisherapps/api/src/modules/publisher/The outbound engine + retries🔲
channelsapps/api/src/modules/channels/Provider adapters (one file per platform)🔲
email-broadcastsapps/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✅
adsapps/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)🔲
attributionapps/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✅
analyticsapps/api/src/modules/analytics/Metric ingestion + the KPI surface🔲
agentsapps/api/src/modules/agents/Scoped keys, agent runs, MCP endpoint🔲

Frontend areas

AreaPathPurposeStatus
Landingapps/web/src/app/(marketing)/page.tsxThe internal front door✅
Dashboardapps/web/src/app/(board)/dashboard/Command centre: features, flows, architecture, tests, labs, docs✅
Style Labapps/web/src/app/style-guide/Live retheming; dev-only save back into globals.css✅
Design systemapps/web/src/components/ui/Primitives + animation library✅
API clientapps/web/src/lib/api.tsaxios + transparent 401 refresh✅
Sessionapps/web/src/lib/auth.tsxSession context✅
Projectsapps/web/src/app/(app)/projects/List, detail, marketing-mix picker, budget table✅
Auth pagesapps/web/src/app/(auth)/Login, signup, social buttons, OAuth callback✅
App shellapps/web/src/app/(app)/app-shell.tsxSession guard + product navigation✅
Billingapps/web/src/app/(app)/billing/Plan, invoices, payment method🔲
Integrationsapps/web/src/app/(app)/integrations/Channels, ad accounts, SEMrush, Apollo, GitHub, WhatsApp🔲
Calendarapps/web/src/app/(app)/calendar/The marketing calendar🔲
Composerapps/web/src/app/(app)/composer/Drafting surface🔲
KPIsapps/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.

JobCadence
Publish scheduled postsevery 15s
Sync inbox messagesevery 5 min per account
Collect post analyticshourly (<48h old), then daily
Collect account + ad metricsdaily
Refresh OAuth tokenshourly, for tokens expiring within 24h
Agent optimisation runsdaily, gated by the approval mode
Cleanup expired datadaily

Deployment

EnvironmentShape
Localdocker compose up -d postgres minio + npm run dev / npm run dev:api
Local, production topologydocker compose --profile apps up -d — four containers
ProductionKubernetes: 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.