---
title: Platform architecture
description: How the Leitagu apps, the shared database, sessions, the chat engine, product data and analytics fit together, and how to run, deploy and test them.
section: platform
order: 1
updated: 2026-10-17
---

# Platform architecture

Leitagu is one repository with five web apps and two shared packages. Each app is deployed on its own subdomain with its own login, and every app except this hub uses the same PostgreSQL database, whose migrations the apps apply themselves when they start. The shop answers chats with its own deterministic code; Claude is optional and only turns free text and room photos into structured data. This page describes the parts, how data moves between them, and how to run, deploy and test the platform.

## Apps and subdomains

The repository is an npm workspaces monorepo. Each app is a separate Next.js project in `apps/`.

| App | Folder | Address | Local port | Who uses it | Login |
| --- | --- | --- | --- | --- | --- |
| Shop | `apps/web` | leitagu.ee | 3000 | Shoppers | Any account (email and password) |
| Admin | `apps/admin` | admin.leitagu.ee | 3001 | The Leitagu team | Admins only |
| Partner | `apps/partner` | partner.leitagu.ee | 3002 | Furniture stores: analytics, products, data connections, team | Store members (by invite) and admins |
| CRM | `apps/crm` | crm.leitagu.ee | 3003 | The Leitagu team: research on potential partner companies | Admins only |
| Knowledge hub | `apps/docs` | docs.leitagu.ee | 3004 | Everyone, including AI agents | None; no database |

- **Which app is which.** Each app's `next.config.ts` sets `LEITAGU_APP` (`web`, `admin`, `partner`, `crm` or `docs`). The shared code reads it to choose the session cookie and to decide which sessions it accepts (`packages/core/src/server/apps.ts`).
- **Links between apps.** `appUrl()` in the same file builds them from `WEB_URL`, `ADMIN_URL`, `PARTNER_URL`, `CRM_URL` and `DOCS_URL`, falling back to the local ports above.
- **One set of accounts.** All apps use the same `users` table. An account's role is `user` or `admin`. Store membership is separate (`store_members`, with the role `owner` or `member`). The same email and password work in every app that accepts the account, but each app needs its own login.

## Shared packages

- **`packages/core` (`@leitagu/core`)**
  - `src/server/db.ts`: the database driver (see below).
  - `src/server/migrations/`: the schema, as ordered migrations. `src/server/schema.mjs` applies them and loads the example data from `src/server/seed/`.
  - `src/server/auth.ts`, `apps.ts`, `http.ts`: passwords, sessions, per-app cookies and the same-origin check.
  - `src/server/data.ts`: products (including the product cache), rooms, chats, messages, favourites and the audit log.
  - `src/server/stores.ts`: stores, members and invites.
  - `src/server/events.ts`: product events and the store analytics queries.
  - `src/server/integrations/`: importing products from Shopify, product feeds and CSV files (`sources.ts`, `normalize.ts`, `fetchers.ts`, `secrets.ts`, `sync.ts`).
  - `src/shared/`: code that runs on both server and browser: the fixed vocabulary, the keyword parser, search and matching, suggestions, room geometry, colour maths, types and translations.
- **`packages/ui`**: shared React components (product cards, the 3D room, the floor plan, the login form, the language switch) and the design tokens.

## The database

There is one PostgreSQL 16+ database for the shop, admin, partner and CRM apps. The hub reads Markdown files and has no database.

`db.ts` offers one small query interface over two drivers:

- **With `DATABASE_URL`:** a PostgreSQL server through the `pg` connection pool. The pool size is `DATABASE_POOL_MAX` (default 10; `npm run dev` sets 4 per app).
- **Without it:** PGlite, an embedded PostgreSQL stored in `PGLITE_DIR` (default `.data/pglite`, or `memory://` for tests).

Both run the same SQL, so the schema and the queries are identical everywhere.

### Migrations

The first time an app process touches the database, it runs the migrations and then the seed step:

- **Migrations.** `migrations/index.mjs` lists them in order. Applied ones are recorded in `schema_migrations`, and each pending one runs in its own transaction.
- **One app at a time.** With a PostgreSQL server, the apps take a session-level advisory lock (`pg_advisory_lock`) around this step. Apps that start together wait for each other, so the migrations run once.
- **Seed.** If the products table is empty, the 50 example products are loaded. Then every product without a store record gets one, and old events get their store filled in. This is safe to run on every start.

| Migration | What it adds |
| --- | --- |
| `001_init` | Users, sessions, products, rooms, room items, chats, messages, favourites, store clicks and the audit log |
| `002_app_sessions` | `sessions.app`: which app a session belongs to |
| `003_stores_partner` | Stores, store members and invites, integrations and their sync runs, product events; on products: store, source, integration, external id, corrections (`overrides`) and data problems (`issues`). Old store clicks move into product events. |
| `004_crm` | The research CRM tables. See [Internal CRM](crm.md#data-model). |
| `005_events_user_fk` | Product events lose their account link when the account is deleted (`ON DELETE SET NULL`). |

**Adding a migration:**

1. Add a file to `packages/core/src/server/migrations/` that exports the SQL as a string.
2. Import it in `migrations/index.mjs` and add it at the end of `MIGRATIONS` with a new name.
3. Prefer statements that are safe to run twice (`IF NOT EXISTS`).
4. Never edit a migration that has shipped. Databases that already applied it will not run it again.

## Sessions and cookies

| App | Cookie | Lifetime |
| --- | --- | --- |
| Shop | `leitagu_session` | 30 days |
| Admin | `leitagu_admin` | 12 hours |
| Partner | `leitagu_partner` | 14 days |
| CRM | `leitagu_crm` | 12 hours |
| Knowledge hub | none | — |

- **Tokens.** The cookie holds 32 random bytes. The database stores only the token's SHA-256 hash as the session id, so a database leak does not leak usable sessions.
- **One app per session.** Every session row records the app that created it (`sessions.app`). An app looks a token up only among its own sessions, so a shop session copied into the admin cookie is not accepted. The distinct cookie names also keep the apps apart on localhost, where cookies ignore the port.
- **Cookie settings.** Host-only, `httpOnly`, `SameSite=Lax`, `Secure` in production, path `/`.
- **Sliding expiry.** When less than half of a session's lifetime is left, the next request extends it to the full lifetime.
- **Signed out.** Expired sessions and closed accounts count as signed out.
- **Who may log in.** The admin app and the CRM accept only admin accounts. The partner app accepts accounts that belong to at least one store, and admins. The shop accepts any account.
- **Admins.** Emails listed in `ADMIN_EMAILS` get the admin role when they sign up or log in. `npm run make-admin -- <email>` promotes an existing account.
- **Login throttling.** After 10 failed attempts for the same email and client address, logins are refused for 15 minutes. The count is kept in memory.
- **Same-origin check.** Every request that changes data (anything but GET and HEAD) is refused if its `Origin` header names another host (`sameOrigin()` in `http.ts`). The API route wrapper applies it, and the login, logout and invite routes call it directly. Requests without an `Origin` header, such as those from non-browser clients, pass.
- **Leaving a store.** When someone is removed from their last store, their partner sessions are deleted.

## How the shop answers a chat

Every chat turn is a request to `POST /api/chats/<id>/messages` with one of: free text, a tapped chip, a changed or removed filter, or a photo. The chat engine (`apps/web/src/lib/server/engine.ts`) handles it and saves the chat's state, the new messages and, when a new list of products is shown, the impressions.

1. **Chips and filter changes** are handled directly. They never call Claude.
2. **Free text** is turned into search settings:
   - **With an API key** (`ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN`), `parseWithClaude()` in `apps/web/src/lib/server/llm.ts` sends Claude the message with a little context: the reply language, the store names, the names of the shopper's rooms, the current settings, the filters the shopper removed, the question being asked, and the name and price of up to ten products on screen. Claude returns structured output: an intent, settings from Leitagu's fixed vocabulary, settings to remove, an optional room name and a one-sentence reply. The model is `claude-opus-5-5`, with structured outputs and the server-side fallback beta. The result is checked again against the vocabulary and the store list.
   - **If there is no key**, or Claude refuses or the call fails, the built-in keyword parser (`fallbackParse()` in `packages/core/src/shared/parse.ts`) reads the message instead, in Estonian or English.
3. **Everything after that is deterministic.** The engine updates the settings, then asks the next guided question or runs `search()` or `suggest()` from `packages/core/src/shared` over the product list in memory. Claude never selects or ranks products, and it sees nothing of the catalogue beyond the names and prices of products already on screen.
4. **Room photos** go to `analyseRoomPhoto()`, which asks Claude to describe the room and its furniture. Each user can analyse 20 photos an hour; the count is kept in memory.

**Without an API key**, the shop still works: the keyword parser handles common phrasings, but not corrections, match strictness or "what should match", and creating a room from a photo is turned off with a message saying why. See [How Leitagu works](../product/how-it-works.md#typing-in-your-own-words). The browser tests run in this mode.

## Product data flow

1. **Source.** Products come from a store's data connection (Shopify, a product feed or an uploaded CSV file), from the admin app (added or edited by hand), or from the example data.
2. **Sync.** `runSync()` in `integrations/sync.ts` reads the source, maps each item with `normalize.ts`, and inserts or updates it in `products` under the id `<store slug>--<external id>`. Products that disappeared from the source are made inactive, not deleted. See the [partner integrations guide](../partners/integrations.md#what-a-sync-does).
3. **Products table.** Synced columns are overwritten on every sync. A store's corrections live in `products.overrides` (JSON) and are applied on top whenever a product is read, so they survive syncs. Data problems such as missing dimensions are recomputed on read.
4. **Product cache.** `getProducts()` keeps every product in memory in each app process. At most every 5 seconds it checks a cheap signature, the row count and the latest `updated_at`, and reloads the whole list if either changed. The app that made a change clears its own cache at once. A sync in the partner app therefore reaches the shop within about 5 seconds.
5. **Search and suggestions.** They run over the cached list. Inactive products, and products without a width, depth and height, are skipped.

## Events and analytics

What shoppers do with products is recorded in one table, `product_events`. The server records impressions when a chat shows a new result list or new suggestions, keeps when a product is kept in a room, favourites when it is saved, and store visits when a shopper goes through the `/go/<product>` link. The browser reports opens and previews in small batches to `/api/events`. Each event stores the product, its store, the type, and where known the chat, the user and whether it was shown in results or suggestions. The partner app reads totals per store with the queries in `events.ts`, and the admin app shows 30-day totals per store. Exact definitions are in [Analytics definitions](../partners/analytics.md).

## Local development

Requirements: Node.js 20.9 or newer.

```bash
npm install
cp .env.example .env.local      # then put your email in ADMIN_EMAILS
npm run dev                     # shop :3000, admin :3001, partner :3002, CRM :3003, docs :3004
```

`npm run dev` runs `scripts/dev.mjs`, which:

- reads `.env.local` and `.env` at the repository root (variables already set in the shell win);
- without `DATABASE_URL`, opens the embedded PGlite database in `.data/pglite`, applies the migrations and example data once, and serves it over the PostgreSQL protocol on 127.0.0.1:5433, so all apps share one database;
- starts each app with `next dev` on its port, with `WEB_URL`, `ADMIN_URL` and the others set to the local addresses.

Variations:

- `npm run dev -- web admin` starts only those apps.
- `npm run dev -- docs` starts only the hub, without a database.
- `npm run build && npm run dev -- --prod` runs production builds with `next start`.
- To start over with an empty database, stop everything and delete `.data/`.

## Deployment

Each app is deployed as its own project, for example one Vercel project per app with the root directory set to `apps/web`, `apps/admin`, `apps/partner`, `apps/crm` or `apps/docs`, and its subdomain pointed at it. Serve everything over HTTPS.

| Variable | Apps | Purpose |
| --- | --- | --- |
| `DATABASE_URL` | shop, admin, partner, CRM | The same managed PostgreSQL 16+ database for all of them. Required in production. |
| `ADMIN_EMAILS` | apps with a login | Comma-separated emails that get the admin role when they sign up or log in. |
| `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`) | shop | Optional. Turns on Claude for free-text chat and room photos. |
| `WEB_URL`, `ADMIN_URL`, `PARTNER_URL`, `CRM_URL`, `DOCS_URL` | all | Public addresses of the apps, used for links between them, in store invite links (`PARTNER_URL`) and for the hub's absolute links (`DOCS_URL`). They default to the local ports. |
| `INTEGRATION_SECRET_KEY` | partner | Key for encrypting stores' integration secrets such as Shopify tokens (AES-256-GCM). Required in production: without it the app refuses to store or read secrets. |
| `CRON_SECRET` | partner | Protects the scheduled sync endpoint. |
| `SHOPIFY_API_VERSION` | partner | Optional. Shopify Admin API version, default `2026-07`. |
| `LEITAGU_EXAMPLE_DATA` | shop, admin, partner, CRM | `off` stops the 50 example products from being loaded into an empty database. Set it for a real launch. |
| `DATABASE_POOL_MAX`, `PGLITE_DIR` | shop, admin, partner, CRM | Optional. Pool size, and where the embedded database lives when there is no `DATABASE_URL`. |

- **Scheduled syncs.** A scheduler must call the partner app every hour: `POST /api/cron/sync` (GET also works) with `Authorization: Bearer $CRON_SECRET`. It runs every enabled Shopify or feed connection that is due, one after another.
- **Migrations** run automatically when an app first uses the database, under the advisory lock described above. That lock needs a direct or session-mode database connection; a transaction-mode pooler can leave an app waiting for it.
- **Step by step.** `DEPLOYMENT.md` in the repository covers hosting on one server or on Vercel, the first admin, checks after a deploy and a security checklist.
- **The hub** is built ahead of time from `apps/docs/content`, needs no database, and uses `DOCS_URL` for the absolute links in `llms.txt`, `index.json` and the sitemap.
- **Staff apps.** The admin and CRM subdomains can be restricted further at the network level, for example with an IP allowlist or single sign-on in front, without code changes.

## Testing

| Command | What it runs |
| --- | --- |
| `npm test` | Unit tests (Vitest, `packages/core/tests`): the keyword parser, search and near misses, room geometry and suggestions, product import (sizes, prices, colours and categories; the CSV template; a Google Merchant XML feed; a semicolon CSV with Estonian headers; a Shopify product), encryption of integration secrets, and refusing feed addresses inside our network. |
| `npm run e2e` | Browser tests (Playwright, `e2e/`) after `npm run build`. They start the shop, admin, partner, CRM and docs apps with `next start` on ports 3210–3214, against a fresh embedded database and without a Claude key. They cover the main shopper flows, admin-only access, the CRM from research to partner store, a store joining by invite and importing a CSV, store pages being closed to outsiders, and the hub's pages and links. |
| `npm run typecheck` | TypeScript check of every package and app. |
| `npm run research:validate` | Checks the research dataset in `data/research` against its schema and source rules. |

Shopify and store feeds are not reachable from the build environment, so those paths are covered by unit tests with sample responses. The CSV path is covered end to end.

## Security notes

- **Passwords.** Hashed with scrypt. A login with an unknown email takes as long as one with a wrong password.
- **Sessions.** Only hashes of session and invite tokens are stored. A session works only in the app that created it.
- **Requests.** Writes must come from the same origin. Rooms, chats and room items are always looked up together with their owner. Partner pages and APIs check store membership, and data connections, the team and store settings need the owner role (or an admin).
- **Admins.** Admin and CRM pages and API routes each check the admin role themselves. Opening a user's chat, room or profile in the admin app writes an audit log entry, as do store, invite, integration, CRM import, conversion and deletion actions.
- **Integration secrets.** Encrypted with AES-256-GCM and never sent to browsers. See [How secrets are stored](../partners/integrations.md#how-secrets-are-stored).
- **Feed addresses.** Requests to addresses that stores give us are limited to public http(s) hosts, with checked redirects and size and time limits. A DNS-rebinding attack is possible in theory, because the address check and the request each resolve the host name. See [Feed fetch safety](../partners/integrations.md#feed-fetch-safety).
- **In-memory limits.** The login throttle, the photo quota and the shopper event budget are kept in memory, so with several server instances each instance counts separately.
- **Shopper privacy towards stores.** Stores see only totals. See [Analytics definitions](../partners/analytics.md#privacy).

## Related pages

- [Partner integrations guide](../partners/integrations.md)
- [Analytics definitions](../partners/analytics.md)
- [Internal CRM](crm.md)
- [Reading this hub as an AI agent](for-agents.md)
- [How Leitagu works](../product/how-it-works.md)
