Architecture
Monorepo layout, package boundaries, and how a request travels from the browser to the database and back.
Monorepo layout
apps/
marketing/ Marketing site (SEO / SEM / GEO / AEO) :3004
web/ Customer-facing app (PWA) :3000
docs/ Documentation and user guide (this site) :3001
platform/ Platform administration :3002
partner/ Partner portal :3003
api/ NestJS API, Better Auth handler, MCP :4000
packages/
env/ Validated env + subdomain topology
db/ Drizzle schema, migrations, createDb()
auth/ Better Auth server, client, Next helpers
email/ Resend mailer + React Email templates
flags/ GrowthBook / FLAGS_JSON feature flags
observability/ Rootprint error-log transport and framework adapters
ui/ shadcn/ui kit, AppShell, GlobalSearch, Combobox
contracts/ zod schemas for API payloads + typed client
ai/ OpenRouter via Vercel AI SDK
whatsapp/ WhatsApp Cloud API client notifications
cli/ vigor CLI + stdio MCP server
config-typescript/ Shared tsconfig presets
kickoff/ CI Lite + scope statement, filled before building
infra/
docker/compose.yml Local application Postgres
docker/rootprint.compose.yml Optional isolated Rootprint/Postgres/Quickwit
railway/ Railway service notes and templates
skills/ Repo-local agent skillsApps are deployable units. Packages are libraries that export TypeScript source directly
through exports (no build step) so that a change in a package is picked up by every app on
the next dev reload. Turborepo tracks the dependency graph and only rebuilds what changed.
Package boundaries
Each package has a documented public API in AGENTS.md. The rule is that apps talk to
packages, packages talk to each other only through those exports, and nobody reaches into
another workspace's src. A few examples:
@vigor/envowns environment parsing. No other package callsprocess.envfor shared configuration; they importserverEnvornextEnvinstead.@vigor/dbowns the schema.@vigor/authreceives adbinstance and adds nothing to it that is not already declared in the schema.@vigor/contractsowns request and response shapes.apps/apivalidates against them and the Next.js apps consume them throughcreateApiClient().
Data flow: web to API to database
Browser (app.<domain>)
|
| fetch, cookies included (same environment cookie scope)
v
Next.js app (apps/web) -- server components, server actions, route handlers
|
| createApiClient({ baseUrl: publicHosts.api })
v
NestJS on Fastify (apps/api) -- /api/* controllers, guards, zod validation
|
| drizzle queries via createDb(DATABASE_URL)
v
PostgreSQL 17Server components in the Next.js apps call the API directly over HTTP using the typed client
from @vigor/contracts. When a request needs the user's session, getServerSession(apiOrigin)
from @vigor/auth/next forwards the incoming cookies to the API, which is the only process
that holds the auth secret and the database connection.
Client components never talk to the database or to third-party providers such as OpenRouter.
They call the API, which applies the guards, rate limits, and CORS rules configured in
apps/api.
Authentication flow across subdomains
Better Auth runs inside the API at /api/auth/*. Every app in the same project and environment
shares one session through the environment's cookie scope. The sequence below shows production;
staging substitutes app.<project>.cenvora.dev and api.<project>.cenvora.dev.
1. User submits credentials on app.<domain>
|
v
2. POST https://api.<domain>/api/auth/sign-in/email
| Better Auth verifies password, creates session row
v
3. Set-Cookie: __Secure-<derived-prefix>.session_token=...; Domain=.<domain>; Secure; HttpOnly; SameSite=Lax
|
v
4. The browser now sends that cookie to app., docs., admin., partner., api.<domain>
|
v
5. Any app resolves the session with getServerSession(publicHosts.api)The cookie domain comes from cookieDomain() in @vigor/env:
.<project>.cenvora.dev in staging, .<ROOT_DOMAIN> in production, and undefined on
localhost. Browsers treat every port on localhost as the same cookie jar, so development needs
no domain attribute. trustedOrigins for Better Auth and the CORS allow-list for Fastify are
both derived from serverHosts, so adding a new app to the topology automatically authorises it.
Separate staging projects do not receive each other's project-domain cookies. That is not full
security isolation: projects remain same-site under the shared cenvora.dev registrable domain,
and cookie-tossing risks remain. Keep every sibling service within a project trusted, and use
separate registrable domains for mutually untrusted projects.
The cookie prefix is derived from the project name and full API host unless explicitly
overridden, so local, staging, and production sessions cannot accidentally reuse the same
cookie name. Organization invitation emails use the configured app origin and link to
/accept-invitation?id=....
MCP and the CLI
The API exposes a stateless Model Context Protocol server at /mcp using Streamable HTTP. Its
tools are health, version, flags_list, and search, with the OpenAPI document available
as openapi://spec. vigor mcp exposes local stdio tools for doctor, host resolution, and
API health/version/search, plus the contributor guide at vigor://agents-md. The two
transports reuse public API contracts while serving different local and remote needs.
Where things run
| Component | Local | Staging and production |
|---|---|---|
| Next.js apps | next dev on ports 3000-3004 | Vercel, one project per app |
| API | bun --watch src/main.ts on 4000 | Railway service |
| PostgreSQL | Docker compose | Railway Postgres |
| Rootprint, GrowthBook | Optional; Rootprint credentials / feature client key are empty locally | Railway services |
| Console output only in explicit development without a key | Resend; missing keys fail in staging/production |