# Multi-tenant platform (https://prood.com/docs/architecture/multi-tenant)

Prood runs as a **white-label, multi-tenant commerce platform**: one deployment serves many merchant stores. Each store is a Better Auth **organization**, and all commerce data is isolated per organization by **Postgres row-level security (RLS)**.

## Domain architecture [#domain-architecture]

| URL | App | Tenant resolution |
| --- | --- | --- |
| `prood.com` | Marketing (`apps/web`, the gateway for the paths below) | N/A |
| `prood.com/dashboard` | Merchant admin | Session `activeOrganizationId` |
| `prood.com/admin` | Platform admin | `admin` role |
| `prood.com/v1`, `/mcp`, `/api/auth` | Commerce API | Session / API key / forwarded `x-storefront-host` |
| `prood.com/pay` | Hosted checkout (`/pay/c/{sessionId}`) | `tenantId` on checkout session |
| `prood.com/docs` | Documentation | N/A |
| `{slug}.prood.app` | Storefront | Host → org slug or custom domain |
| `shop.example.com` | Storefront | Verified `tenant_domain` row |

`NEXT_PUBLIC_PLATFORM_DOMAIN=prood.app` applies **only** to merchant storefront subdomains (`{slug}.prood.app`), not to platform services on `prood.com`, which share that one domain and are told apart by path.

## Tenant model [#tenant-model]

```mermaid
flowchart TB
  ORG["Better Auth organization (tenant)"]
  WT["withTenant(orgId)"]
  SET["set app.current_org_id + SET LOCAL ROLE prood_app<br/>(per-request transaction)"]
  RLS["RLS policy filters every row<br/>by organization_id"]

  ORG --> WT --> SET --> RLS
```

| Surface | Tenant source | Mechanism |
| --- | --- | --- |
| **Dashboard** | `session.activeOrganizationId` | `withActiveOrg()` → `withTenant()` |
| **Storefront** | Request `Host` header | `resolveTenantId()` → API calls with tenant context |
| **API** | Caller resolution | JWT / API key / session / Host → `orgId` |
| **Checkout** | Session metadata | `tenantId` stored on checkout session at creation |

## Storefront host resolution [#storefront-host-resolution]

The storefront resolves the active tenant in `apps/storefront/lib/tenant.ts`:

1. **Custom domain** — lookup verified row in `tenant_domain` table
2. **Subdomain** — `{slug}.{NEXT_PUBLIC_PLATFORM_DOMAIN}` → `organization.slug`
Unmatched hosts redirect to the marketing site. Locally, the seeded demo store is served at `prood-demo.localhost:3000`.

### Per-tenant caching [#per-tenant-caching]

Catalog reads are cached **per tenant** with tags like `products-{orgId}`, `categories-{orgId}`, `store-{orgId}`. Cache never crosses tenant boundaries.

## Row-level security [#row-level-security]

RLS is defined in the Drizzle schema and shipped as versioned migrations in `packages/platform/drizzle`:

1. `0000_app_role.sql` creates the `prood_app` role (`NOLOGIN NOBYPASSRLS`), lets the connecting role `SET ROLE` to it, and grants it `SELECT, INSERT, UPDATE, DELETE` on the `public` schema, including default privileges for tables created by later migrations.
2. Every tenant table uses the helpers in `packages/platform/src/database/drizzle/schema/tenant.ts`: a `NOT NULL organization_id` column that defaults to `app.current_org_id`, and a `tenant_isolation` `pgPolicy` for `prood_app` (`USING` and `WITH CHECK` on `organization_id`). Drizzle enables RLS on every table with a policy.

The app connects with a role that has `BYPASSRLS` (on Neon, the database owner), so RLS is enforced by switching roles, not with `FORCE ROW LEVEL SECURITY`. Every commerce query runs inside `withTenant(orgId)`:

```ts
// packages/platform/src/database/drizzle/client.ts
export async function withTenant<T>(orgId: string, fn: () => Promise<T>): Promise<T> {
  return getBaseDb().transaction(async (tx) => {
    await tx.execute(sql`select set_config('app.current_org_id', ${orgId}, true)`)
    await tx.execute(sql`set local role prood_app`)
    return tenantStore.run({ db: tx, organizationId: orgId }, fn)
  })
}
```

Both settings are transaction-local, so a pooled connection returns to the connecting role afterwards. Queries also add `tenantCondition()` (`organization_id = <active org>`) as defense in depth.

Paths that must read across tenants stay outside `withTenant()` and run as the connecting role: migrations and the seed, host → tenant resolution (`organization`, `tenant_domain`), Better Auth, and linking a new account to its guest customers in every store.

Unique keys that repeat across stores include `organization_id`: product, category and brand slugs, order numbers, coupon codes, customer ↔ auth-user links, and the `store_info` primary key.

### Verifying isolation [#verifying-isolation]

Run the same statements `withTenant()` runs, against a database with two organizations:

```sql
BEGIN;
SELECT set_config('app.current_org_id', '<org A id>', true);
SET LOCAL ROLE prood_app;
SELECT id, organization_id FROM products;                      -- only A's rows
SELECT id FROM products WHERE organization_id = '<org B id>';  -- 0 rows
INSERT INTO products (id, organization_id, name, slug)
VALUES ('x', '<org B id>', '{}', 'x');
-- ERROR: new row violates row-level security policy for table "products"
ROLLBACK;

-- prood_app without a tenant sees nothing
BEGIN;
SET LOCAL ROLE prood_app;
SELECT count(*) FROM products;  -- 0
ROLLBACK;
```

`packages/platform/src/__tests__/platform.drizzle.test.ts` covers the same cases.

## Onboarding a new merchant [#onboarding-a-new-merchant]

1. **Register** in the dashboard → creates a user + first organization (the store)
2. The org ID becomes the tenant key for all commerce data
3. **Add a domain** in Dashboard → Domains (subdomain is automatic; custom domains use Vercel SDK + DNS verification)
4. The storefront at that host resolves to the org and shows the merchant's catalog

See [Merchant onboarding guide](/docs/guides/merchant-onboarding) for the full walkthrough.

## Per-tenant integrations [#per-tenant-integrations]

Payment credentials configured in the dashboard (`integration_config` table) flow into the provider factory at runtime:

| Step | What happens |
| --- | --- |
| Dashboard save | Credentials encrypted with AES-256-GCM (`encryptConfig`) and stored per org |
| Storefront checkout | Sends `tenantId` when creating a checkout session |
| Checkout pay | Rebuilds payment provider with tenant's decrypted credentials |
| Webhooks | Routed per tenant at `/api/webhooks/{provider}/{orgId}` |

Provider registry field keys in `apps/dashboard/lib/providers.ts` match provider constructor params, so stored config maps directly.

### Encryption at rest [#encryption-at-rest]

`integration_config.config` values are encrypted using `INTEGRATION_ENCRYPTION_KEY`. The key is required. Values without the `enc:v1:` prefix are treated as plaintext during development or migration cleanup.

## Storage namespacing [#storage-namespacing]

File uploads use tenant-prefixed keys:

```ts
uploadForTenant(orgId, input)       // key: org/<orgId>/...
tenantStorageDirectory(orgId, ...)  // directory prefix
```

Merchants cannot collide with each other's assets. Every object is public through the media Worker, and uploaded filenames get a short random suffix.

## Package security posture [#package-security-posture]

| Package | Touches tenant data? | Isolation |
| --- | --- | --- |
| `platform` | Yes — owns schema | RLS as `prood_app` + `withTenant()` |
| `commerce` | Yes — wraps platform | Tenant threaded; per-tenant cache tags |
| `checkout-host` | Yes — sessions | `tenantId` on session; provider rebuilt per tenant |
| `checkout` | Per-session state | New instance per session; no module-level mutable state |
| `payment-*` | Credentials only | Stateless; config injected per tenant |
| `storage-*` | File uploads | Tenant-namespaced keys |
| `types`, `ui`, configs | No | Safe |

## Environment variables [#environment-variables]

| Variable | Purpose |
| --- | --- |
| `NEXT_PUBLIC_PLATFORM_DOMAIN` | Apex for `{slug}.prood.app` **storefront** subdomains only — not platform hosts on `prood.com` |
| `INTEGRATION_ENCRYPTION_KEY` | Encrypt stored provider credentials |
| `VERCEL_TOKEN`, `STOREFRONT_VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID` | Custom domain provisioning (optional in dev) |

## Adding new tenant tables [#adding-new-tenant-tables]

When adding a new tenant-owned table:

1. Define it in `packages/platform/src/database/drizzle/schema/` with `organizationId: organizationId()` and `tenantIsolation()` in the table's extra config (see `tenant.ts`), plus an index on `organization_id` or a unique key that starts with it
2. If a natural key repeats across tenants, make it unique per tenant (`unique().on(t.organizationId, t.slug)`) or include `organization_id` in the primary key (see `store_info`)
3. Run `pnpm db:generate` and commit the generated migration, then `pnpm db:migrate`
4. Make sure its queries run inside `withTenant()` and add `tenantCondition()`

## Custom domains (two products) [#custom-domains-two-products]

Merchants can have **two different kinds** of custom domain:

| Type | Default | Custom (paid tiers vary) | Routes to | Purpose |
| --- | --- | --- | --- | --- |
| **Store domain** | `{slug}.prood.app` | e.g. `loja.casalume.example` | Storefront | Customer-facing catalog and cart |
| **Admin domain (white-label)** | `prood.com/dashboard` | e.g. `admin.casalume.example` | Dashboard | Team-facing admin (DatoCMS-style) |

### Store custom domain (partially implemented) [#store-custom-domain-partially-implemented]

- Configured in Dashboard → **Domains**
- Stored in `tenant_domain`, verified via DNS
- Attached to the **storefront** Vercel project (`STOREFRONT_VERCEL_PROJECT_ID`)
- Free plan includes one verified store custom domain (`maxCustomDomains` in billing)

### Admin white-label domain (Phase 3 — not implemented) [#admin-white-label-domain-phase-3--not-implemented]

Similar to DatoCMS `dashboard.datocms.com` vs `admin.casalume.example`:

- Default admin URL for all merchants: **`prood.com/dashboard`**
- Scale / Agency (planned): optional **`admin.casalume.example`** with DNS verification
- Routes to `apps/dashboard`, not the storefront
- Requires a separate Vercel project attachment, dashboard host → org lookup, and Better Auth trusted origins per custom admin host
- Planned billing gate: `customAdminDomain` entitlement on Scale and Agency only

Store and admin custom domains are **independent** — a merchant might use `shop.example.com` for customers and `cms.example.com` for their team.

## Related pages [#related-pages]

<Cards>
  <Card title="Dashboard domains" href="/docs/apps/dashboard/domains" description="Custom domain setup and Vercel integration." />
  <Card title="Dashboard integrations" href="/docs/apps/dashboard/integrations" description="Per-tenant payment and service credentials." />
  <Card title="API authentication" href="/docs/apps/api/authentication" description="How the API resolves tenant from callers." />
  <Card title="Merchant onboarding" href="/docs/guides/merchant-onboarding" description="Step-by-step new merchant setup." />
</Cards>
