Lee Kong Si · 李氏总会

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 skills

Apps 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/env owns environment parsing. No other package calls process.env for shared configuration; they import serverEnv or nextEnv instead.
  • @vigor/db owns the schema. @vigor/auth receives a db instance and adds nothing to it that is not already declared in the schema.
  • @vigor/contracts owns request and response shapes. apps/api validates against them and the Next.js apps consume them through createApiClient().

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 17

Server 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

ComponentLocalStaging and production
Next.js appsnext dev on ports 3000-3004Vercel, one project per app
APIbun --watch src/main.ts on 4000Railway service
PostgreSQLDocker composeRailway Postgres
Rootprint, GrowthBookOptional; Rootprint credentials / feature client key are empty locallyRailway services
EmailConsole output only in explicit development without a keyResend; missing keys fail in staging/production

On this page