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 x-storefront-host
checkout.prood.comHosted checkout (/c/{sessionId})tenantId on checkout session
{slug}.prood.appStorefrontHost → org slug or custom domain
shop.example.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 Unmatched hosts redirect to the marketing site. Locally, the seeded demo store is served at prood-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:

  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

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

  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:

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

PackageTouches tenant data?Isolation
platformYes — owns schemaRLS as prood_app + 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
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. 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)

Merchants can have two different kinds of custom domain:

TypeDefaultCustom (paid tiers vary)Routes toPurpose
Store domain{slug}.prood.appe.g. loja.casalume.exampleStorefrontCustomer-facing catalog and cart
Admin domain (white-label)dashboard.prood.come.g. admin.casalume.exampleDashboardTeam-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 admin.casalume.example:

  • Default admin URL for all merchants: dashboard.prood.com
  • 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.