Multi-tenant platform
How Prood isolates merchant data with Better Auth organizations, host-based tenant resolution, and Postgres row-level security.
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
| URL | App | Tenant resolution |
|---|---|---|
prood.com | Marketing | N/A |
dashboard.prood.com | Merchant admin | Session activeOrganizationId |
api.prood.com | Commerce API | Session / API key / forwarded x-storefront-host |
checkout.prood.com | Hosted checkout (/c/{sessionId}) | tenantId on checkout session |
{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.
Tenant model
Drag to pan · Scroll to zoom
| 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
The storefront resolves the active tenant in apps/storefront/lib/tenant.ts:
- Custom domain — lookup verified row in
tenant_domaintable - Subdomain —
{slug}.{NEXT_PUBLIC_PLATFORM_DOMAIN}→organization.slugUnmatched hosts redirect to the marketing site. Locally, the seeded demo store is served atprood-demo.localhost:3000.
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
RLS is defined in the Drizzle schema and shipped as versioned migrations in packages/platform/drizzle:
0000_app_role.sqlcreates theprood_approle (NOLOGIN NOBYPASSRLS), lets the connecting roleSET ROLEto it, and grants itSELECT, INSERT, UPDATE, DELETEon thepublicschema, including default privileges for tables created by later migrations.- Every tenant table uses the helpers in
packages/platform/src/database/drizzle/schema/tenant.ts: aNOT NULL organization_idcolumn that defaults toapp.current_org_id, and atenant_isolationpgPolicyforprood_app(USINGandWITH CHECKonorganization_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):
// 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
Run the same statements withTenant() runs, against a database with two organizations:
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
- Register in the dashboard → creates a user + first organization (the store)
- The org ID becomes the tenant key for all commerce data
- Add a domain in Dashboard → Domains (subdomain is automatic; custom domains use Vercel SDK + DNS verification)
- The storefront at that host resolves to the org and shows the merchant's catalog
See Merchant onboarding guide for the full walkthrough.
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
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
File uploads use tenant-prefixed keys:
uploadForTenant(orgId, input) // key: org/<orgId>/...
tenantStorageDirectory(orgId, ...) // directory prefixMerchants 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 | 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
| 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
When adding a new tenant-owned table:
- Define it in
packages/platform/src/database/drizzle/schema/withorganizationId: organizationId()andtenantIsolation()in the table's extra config (seetenant.ts), plus an index onorganization_idor a unique key that starts with it - If a natural key repeats across tenants, make it unique per tenant (
unique().on(t.organizationId, t.slug)) or includeorganization_idin the primary key (seestore_info) - Run
pnpm db:generateand commit the generated migration, thenpnpm db:migrate - Make sure its queries run inside
withTenant()and addtenantCondition()
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) | dashboard.prood.com | e.g. admin.casalume.example | Dashboard | Team-facing admin (DatoCMS-style) |
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 (
maxCustomDomainsin billing)
Admin white-label domain (Phase 3 — not implemented)
Similar to DatoCMS dashboard.datocms.com vs admin.casalume.example:
- Default admin URL for all merchants:
dashboard.prood.com - Scale / Agency (planned): optional
admin.casalume.examplewith 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:
customAdminDomainentitlement 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.