Prood
Architecture

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

URLAppTenant resolution
prood.comMarketingN/A
dashboard.prood.comMerchant adminSession activeOrganizationId
api.prood.comCommerce APISession / API key / forwarded store Host
checkout.prood.comHosted checkout (/c/{sessionId})tenantId on checkout session
{slug}.prood.appStorefrontHost → org slug or custom domain
shop.client.comStorefrontVerified 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

SurfaceTenant sourceMechanism
Dashboardsession.activeOrganizationIdwithActiveOrg()withTenant()
StorefrontRequest Host headerresolveTenantId() → API calls with tenant context
APICaller resolutionJWT / API key / session / Host → orgId
CheckoutSession metadatatenantId stored on checkout session at creation

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
  3. FallbackDEFAULT_TENANT_ORG_ID (local dev only; set explicitly after seed)

In production, an unmatched host returns notFound(). In development, set DEFAULT_TENANT_ORG_ID to your seeded organization id.

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

applyTenantIsolation() (in packages/platform/src/database/drizzle/migrate.ts):

  1. Adds organization_id column to every tenant table (defaulting to current_setting('app.current_org_id'))
  2. Enables and forces RLS with a tenant_isolation policy on each table

Every commerce query must run inside withTenant(orgId), which sets the Postgres session variable within a transaction:

// packages/platform
export async function withTenant<T>(orgId: string, fn: () => Promise<T>): Promise<T> {
  return getDb().transaction(async (tx) => {
    await tx.execute(sql`SELECT set_config('app.current_org_id', ${orgId}, true)`)
    return fn()
  })
}

Verifying isolation

Test against a real database with two organizations:

-- Set tenant A
SELECT set_config('app.current_org_id', '<org A id>', false);
SELECT id, name, organization_id FROM products;   -- only A's rows

-- Switch to tenant B
SELECT set_config('app.current_org_id', '<org B id>', false);
SELECT id, name, organization_id FROM products;   -- only B's rows

-- No setting → zero rows (writes blocked too)
RESET app.current_org_id;
SELECT id, name FROM products;   -- empty

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 for the full walkthrough.

Per-tenant integrations

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

StepWhat happens
Dashboard saveCredentials encrypted with AES-256-GCM (encryptConfig) and stored per org
Storefront checkoutSends tenantId when creating a checkout session
Checkout payRebuilds payment provider with tenant's decrypted credentials
WebhooksRouted 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 prefix

Merchants cannot collide with or read each other's assets. Vercel Blob uploads use addRandomSuffix for unguessable URLs.

Package security posture

PackageTouches tenant data?Isolation
platformYes — owns schemaForced RLS + withTenant()
commerceYes — wraps platformTenant threaded; per-tenant cache tags
checkout-hostYes — sessionstenantId on session; provider rebuilt per tenant
checkoutPer-session stateNew instance per session; no module-level mutable state
payment-*Credentials onlyStateless; config injected per tenant
storage-*File uploadsTenant-namespaced keys
types, ui, configsNoSafe

Environment variables

VariablePurpose
DEFAULT_TENANT_ORG_IDExplicit fallback tenant (single-tenant / dev)
NEXT_PUBLIC_PLATFORM_DOMAINApex for {slug}.prood.app storefront subdomains only — not platform hosts on prood.com
INTEGRATION_ENCRYPTION_KEYEncrypt stored provider credentials
VERCEL_TOKEN, STOREFRONT_VERCEL_PROJECT_ID, VERCEL_TEAM_IDCustom domain provisioning (optional in dev)

Adding new tenant tables

When adding a new tenant-owned table:

  1. Add it to TENANT_TABLES in packages/platform/src/database/drizzle/migrate.ts
  2. If the natural key repeats across tenants, include organization_id in the primary key (see store_info and integrations)
  3. Re-run migrations and verify RLS policies apply

Custom domains (two products)

Merchants can have two different kinds of custom domain:

TypeDefaultCustom (paid tiers vary)Routes toPurpose
Store domain{slug}.prood.appe.g. shop.my-brand.comStorefrontCustomer-facing catalog and cart
Admin domain (white-label)dashboard.prood.come.g. cms.my-brand.comDashboardTeam-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 (maxCustomDomains in billing)

Admin white-label domain (Phase 3 — not implemented)

Similar to DatoCMS dashboard.datocms.com vs cms.your-brand.com:

  • Default admin URL for all merchants: dashboard.prood.com
  • Scale / Agency (planned): optional cms.my-brand.com 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.acme.com for customers and cms.acme.com for their team.

On this page