Skip to content

Multi-Tenancy System

The multi-tenancy system enables white-label distribution of the Accessible PDF Converter. Partners (universities, agencies, resellers) get their own domain, branding, and user base while AnglinAI manages the infrastructure.

Overview

  • Model: Reseller/partner β€” AnglinAI manages infrastructure, partners manage their users
  • Domains: Custom domains (e.g., pdf.stanford.edu) with DNS verification
  • Branding: Full white-label β€” logo, colors, app name, emails, favicon, legal pages, all UI copy
  • Billing: Per-user credits (unchanged), with tenant-scoped credit packages
  • i18n: Tenant content overrides merge on top of base locale strings
  • Isolation: Row-level security (RLS) ensures tenants only see their own data

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Partner Domain │───▢│ Cloudflare Worker │───▢│ Supabase β”‚
β”‚ pdf.stanford.eduβ”‚ β”‚ (API + Tenant MW) β”‚ β”‚ PostgreSQL β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ └──────────────────┐
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Cloudflare KV β”‚ β”‚ Cloudflare R2 β”‚
β”‚ (tenant cache) β”‚ β”‚ (tenant assets) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Next.js SPA β”‚ Fetches /api/tenant/config on load,
β”‚ (Static Export) β”‚ applies branding via CSS variables
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Request Flow

  1. User visits pdf.stanford.edu
  2. Next.js SPA loads, TenantProvider fetches GET /api/tenant/config?domain=pdf.stanford.edu
  3. API resolves domain β†’ tenant via tenant_domains table (cached in KV)
  4. Returns full TenantConfig (colors, logos, content overrides, legal, SEO)
  5. Frontend applies CSS variables, updates <title>, favicon, meta tags
  6. Subsequent API requests include X-Tenant-ID header; API middleware resolves tenant context

Database Schema

Migration: supabase/migrations/20250222_004_multi_tenancy.sql

New Tables

tenants

Core tenant registry.

ColumnTypeDescription
idUUID (PK)Tenant identifier
slugTEXT (UNIQUE)URL-safe identifier (e.g., stanford)
display_nameTEXTHuman-readable name
statusTEXTactive, suspended, or pending
admin_emailTEXTPrimary admin contact
support_emailTEXTSupport contact (nullable)
max_usersINTEGERUser limit (nullable = unlimited)
max_monthly_pagesINTEGERMonthly processing quota (nullable)
metadataJSONBExtensible custom data
created_atTIMESTAMPTZCreation time
updated_atTIMESTAMPTZLast update time

A default tenant (slug='default') is created by the migration.

tenant_domains

Maps hostnames to tenants. Each domain must be unique across all tenants.

ColumnTypeDescription
idUUID (PK)Domain record ID
tenant_idUUID (FK)References tenants.id
domainTEXT (UNIQUE)Hostname (e.g., pdf.stanford.edu)
is_primaryBOOLEANWhether this is the tenant’s primary domain
verifiedBOOLEANDNS verification status
verification_tokenTEXTToken for DNS TXT record verification
verified_atTIMESTAMPTZWhen verification passed

Default entries: pdf.anglin.com (primary) and localhost (dev).

tenant_branding

Full visual and email customization. One row per tenant (tenant_id is the primary key).

Column GroupColumns
Identityapp_name, app_short_name, tagline
Colorscolor_primary, color_primary_hover, color_primary_light, color_secondary, color_accent, color_header_bg, color_header_text, color_footer_bg, color_footer_text
Assetslogo_url, logo_dark_url, favicon_url, og_image_url
Emailemail_from_name, email_from_address, email_logo_url, email_footer_text
SEOmeta_title, meta_description
Featuresshow_powered_by (boolean), show_pricing_page (boolean)
Legalcustom_css, privacy_policy_html, terms_html, privacy_policy_url, terms_url

tenant_content

Key-value content overrides per tenant (i18n and copy customization).

ColumnTypeDescription
idUUID (PK)Content block ID
tenant_idUUID (FK)References tenants.id
content_keyTEXTDot-notation key (e.g., hero.title)
content_typeTEXTtext, html, or json
contentTEXTThe content value
localeTEXTLanguage code (default en)

Unique constraint: (tenant_id, content_key, locale).

tenant_admins

Partner admin users with role-based access.

ColumnTypeDescription
idUUID (PK)Admin record ID
tenant_idUUID (FK)References tenants.id
user_idUUID (FK)References auth.users.id
roleTEXTadmin, manager, or viewer

Unique constraint: (tenant_id, user_id).

Modified Existing Tables

TableChangePurpose
profilesAdded tenant_id UUID NOT NULL (FK)Associates each user with a tenant
credit_packagesAdded tenant_id UUID (FK, nullable)NULL = available to all tenants; specific value = tenant-only
contact_submissionsAdded tenant_id UUID (FK, nullable)Tracks which tenant a contact form was submitted from
webhook_eventsAdded tenant_id UUID (FK, nullable)Associates webhook events with tenants

Database Functions

resolve_tenant_by_domain(p_domain TEXT) β†’ UUID Looks up a verified domain in tenant_domains, joins tenants to confirm status = 'active', and falls back to the default tenant if not found.

is_tenant_admin(p_user_id UUID, p_tenant_id UUID) β†’ BOOLEAN Returns TRUE if the user is a super admin (profiles.role = 'admin') or has an entry in tenant_admins for the given tenant.

handle_new_user() trigger (updated) When a new user signs up, reads tenant_id from raw_user_meta_data (set by the frontend during signup). Falls back to the default tenant if not provided.

Row-Level Security

All tenant tables have RLS enabled:

  • Reads: Branding, content, and active tenant info are publicly readable (needed for unauthenticated config fetch)
  • Writes: Tenant admins can modify their own tenant’s branding, content, and domains. Super admins (profiles.role = 'admin') can manage all tenants.
  • User isolation: Users can only see their own profile and data within their tenant.

Tenant Resolution (API Middleware)

File: workers/api/src/middleware/tenant.ts

The tenant middleware runs on every API request and resolves the tenant using this priority order:

PrioritySourceHow
1X-Tenant-ID headerExplicit header set by the frontend after initial config fetch
2Origin / Referer hostnameDomain lookup in tenant_domains (verified only)
3Authenticated user’s profileReads profiles.tenant_id
4Default tenantFallback to tenant with slug = 'default'

Once resolved, the middleware sets c.set('tenantId', tenantId) for downstream route handlers.

KV Caching

Domain-to-tenant lookups are cached in Cloudflare KV (KV_SESSIONS binding) with a 5-minute TTL:

Key PatternValueTTL
tenant-domain:{hostname}Tenant UUID5 min
tenant-default-idDefault tenant UUID5 min
tenant-config:{tenantId}Serialized TenantConfig JSON5 min

Cache is invalidated when branding, content, or domains are updated via the admin API.

CORS

File: workers/api/src/index.ts

The CORS middleware dynamically allows origins based on verified tenant domains:

  1. Always allows the configured FRONTEND_URL
  2. In non-production, allows any localhost origin
  3. For all other origins, calls isDomainVerified(hostname) which checks tenant_domains for a verified entry
  4. The X-Tenant-ID header is included in allowHeaders

Frontend Integration

TenantProvider

File: apps/web/src/lib/tenant-context.tsx

The TenantProvider wraps the app (inside AuthProvider, in layout.tsx) and:

  1. On mount, reads window.location.hostname
  2. Fetches GET /api/tenant/config?domain={hostname}
  3. Stores the TenantConfig in React context
  4. Applies CSS custom properties to document.documentElement
  5. Updates document.title, meta description, OG tags, and favicon

Falls back to a hardcoded default config if the API request fails.

CSS Variables

File: apps/web/src/app/globals.css

Default values are set in :root and overridden by TenantProvider:

:root {
--tenant-primary: #0284c7;
--tenant-primary-hover: #0369a1;
--tenant-primary-light: #f0f9ff;
--tenant-header-bg: #ffffff;
--tenant-header-text: #0f172a;
--tenant-footer-bg: #0f172a;
--tenant-footer-text: #e2e8f0;
}

Components like .btn-primary, .input:focus, and .skip-link reference these variables for tenant-aware theming.

Hooks

HookReturnsUsage
useTenant()TenantConfigAccess full tenant config (colors, logos, content, etc.)
useTenantLoading()booleanCheck if tenant config is still loading
useTenantContent(key, fallback)stringGet a tenant content override with fallback
useTenantJsonContent<T>(key, fallback)TParse JSON content block with typed fallback

i18n Integration

File: apps/web/src/lib/i18n.ts

The useTranslation() hook returns a function t(key, values?) that:

  1. Checks tenant content overrides first (tenant.content[key])
  2. Falls back to base locale strings (locales/en.json)
  3. Returns the key itself if not found (development fallback)

Supports interpolation: t('hero.welcome', { name: 'John' }) replaces {name} in the template.

Base locale strings live in apps/web/locales/en.json as a flat key-value object with dot-notation keys (e.g., hero.title, auth.signIn, common.save).

API Routes

Public Endpoint

MethodPathAuthDescription
GET/api/tenant/config?domain={hostname}NoReturns full TenantConfig for the given domain

Super-Admin Routes

Require profiles.role = 'admin'.

MethodPathDescription
GET/api/tenant-admin/tenantsList all tenants (paginated)
POST/api/tenant-admin/tenantsCreate a new tenant
GET/api/tenant-admin/tenants/:idTenant detail with domains, admins, user count
PATCH/api/tenant-admin/tenants/:idUpdate tenant settings
GET/api/tenant-admin/tenants/:id/statsUser count and total credits in circulation

Tenant Admin Routes

Require tenant_admins membership (or super-admin).

MethodPathDescription
GET/api/tenant-admin/brandingGet tenant branding
PUT/api/tenant-admin/brandingUpdate branding (colors, logos, emails, legal, etc.)
GET/api/tenant-admin/contentList content overrides
PUT/api/tenant-admin/content/:keyUpsert a content block
GET/api/tenant-admin/usersList users in tenant (paginated, searchable)
GET/api/tenant-admin/statsTenant stats
GET/api/tenant-admin/domainsList domains
POST/api/tenant-admin/domainsAdd a domain (returns DNS instructions)
POST/api/tenant-admin/domains/:id/verifyVerify domain via DNS TXT record
DELETE/api/tenant-admin/domains/:idRemove a domain

Email White-Labeling

File: workers/api/src/services/email.ts

All transactional emails accept an EmailBranding parameter:

interface EmailBranding {
appName: string; // e.g., "Stanford PDF Tools"
fromName: string; // e.g., "Stanford PDF Tools"
fromAddress: string; // e.g., "[email protected]"
primaryColor: string; // hex color for CTA buttons
logoUrl: string | null; // logo in email header
footerText: string; // footer branding text
}

The buildEmailHtml(branding, bodyContent) helper generates a complete HTML email template with the tenant’s logo, colors, and footer text. Magic link emails, alert emails, and future transactional emails all use this system.

Default branding uses β€œAccessible PDF Converter” and #0284c7 for the default tenant.

Custom Domain Management

Adding a Domain

  1. Tenant admin calls POST /api/tenant-admin/domains with { domain: "pdf.stanford.edu" }
  2. API generates a verification_token (UUID) and returns DNS instructions:
    • CNAME: pdf.stanford.edu β†’ pdf.anglin.com
    • TXT: _apdf-verify.pdf.stanford.edu β†’ {verification_token}
  3. Partner configures DNS records

Verifying a Domain

  1. Tenant admin calls POST /api/tenant-admin/domains/:id/verify
  2. API queries Cloudflare DNS-over-HTTPS: https://cloudflare-dns.com/dns-query?name=_apdf-verify.{domain}&type=TXT
  3. Checks if any TXT record contains the stored verification_token
  4. On success: marks domain as verified = true, clears KV cache
  5. Domain is now active for tenant resolution and CORS

Tenant-Scoped Billing

File: workers/api/src/routes/credits.ts

Credit Packages

The credit_packages.tenant_id column controls package visibility:

  • NULL β€” available to all tenants
  • Specific UUID β€” only available to that tenant

The GET /api/credits/packages endpoint filters by tenant_id IS NULL OR tenant_id = {userTenantId}.

Stripe Checkout

When a user purchases credits, the checkout session uses the tenant’s context:

  • Success/cancel URLs use the tenant’s verified primary domain instead of the default FRONTEND_URL
  • Product name includes the tenant’s app name (e.g., β€œStanford PDF Tools - Starter Pack - 100 Credits”)
  • Session metadata includes tenant_id for webhook reconciliation

Admin UI

Tenant Admin Pages (/tenant-admin/)

PagePathPurpose
Dashboard/tenant-adminUser count, conversion stats, links to sub-pages
Branding/tenant-admin/brandingColor pickers, logo URLs, app name, feature flags, live preview
Content/tenant-admin/contentKey-value editor for hero text, features, FAQ, etc.
Users/tenant-admin/usersUser list with search and pagination
Domains/tenant-admin/domainsAdd/verify/remove custom domains with DNS instructions

Super-Admin Pages (/admin/tenants/)

PagePathPurpose
Tenant List/admin/tenantsAll tenants with status, domains, user count
Create Tenant/admin/tenants/newWizard: slug, name, admin email, initial domain
Tenant Detail/admin/tenants/detail?id={uuid}Edit settings, view domains/admins/stats

R2 Storage Paths

File: packages/shared/src/constants.ts

Tenant assets are stored under namespaced R2 paths:

tenants/{tenantId}/assets/{filename} β€” logos, favicons, branding assets
tenants/{tenantId}/users/{userId}/uploads/{fileId} β€” user uploads (new tenants)
tenants/{tenantId}/users/{userId}/output/{fileId} β€” conversion output (new tenants)

Existing default tenant files remain at their original paths for backward compatibility.

Creating a New Tenant

  1. Super admin navigates to /admin/tenants/new
  2. Fills in: slug, display name, admin email, (optional) support email, domain, user/page limits
  3. API creates:
    • tenants row
    • tenant_branding row with default colors
    • tenant_domains row (if domain provided) with verification token
  4. Super admin shares DNS instructions with the partner
  5. Partner configures CNAME + TXT records
  6. Tenant admin verifies domain via /tenant-admin/domains
  7. Tenant admin customizes branding and content via /tenant-admin/branding and /tenant-admin/content
  8. Users signing up on the partner domain are automatically assigned to the tenant via the handle_new_user() trigger

Key Files

FilePurpose
supabase/migrations/20250222_004_multi_tenancy.sqlDatabase schema, RLS policies, functions
packages/shared/src/types.tsTenantConfig and EmailBranding interfaces
packages/shared/src/constants.tsTenant R2 storage paths
workers/api/src/middleware/tenant.tsTenant resolution middleware + KV caching
workers/api/src/routes/tenant.tsPublic config endpoint
workers/api/src/routes/tenant-admin.tsAdmin CRUD, branding, content, domains
workers/api/src/index.tsCORS + middleware mounting
workers/api/src/services/email.tsWhite-labeled email templates
workers/api/src/types/hono-env.tsHono context type declarations
apps/web/src/lib/tenant-context.tsxTenantProvider + hooks
apps/web/src/lib/i18n.tsTranslation hook with tenant overrides
apps/web/src/app/globals.cssCSS custom property defaults
apps/web/locales/en.jsonBase locale strings