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
- User visits
pdf.stanford.edu - Next.js SPA loads,
TenantProviderfetchesGET /api/tenant/config?domain=pdf.stanford.edu - API resolves domain β tenant via
tenant_domainstable (cached in KV) - Returns full
TenantConfig(colors, logos, content overrides, legal, SEO) - Frontend applies CSS variables, updates
<title>, favicon, meta tags - Subsequent API requests include
X-Tenant-IDheader; API middleware resolves tenant context
Database Schema
Migration: supabase/migrations/20250222_004_multi_tenancy.sql
New Tables
tenants
Core tenant registry.
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Tenant identifier |
slug | TEXT (UNIQUE) | URL-safe identifier (e.g., stanford) |
display_name | TEXT | Human-readable name |
status | TEXT | active, suspended, or pending |
admin_email | TEXT | Primary admin contact |
support_email | TEXT | Support contact (nullable) |
max_users | INTEGER | User limit (nullable = unlimited) |
max_monthly_pages | INTEGER | Monthly processing quota (nullable) |
metadata | JSONB | Extensible custom data |
created_at | TIMESTAMPTZ | Creation time |
updated_at | TIMESTAMPTZ | Last 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.
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Domain record ID |
tenant_id | UUID (FK) | References tenants.id |
domain | TEXT (UNIQUE) | Hostname (e.g., pdf.stanford.edu) |
is_primary | BOOLEAN | Whether this is the tenantβs primary domain |
verified | BOOLEAN | DNS verification status |
verification_token | TEXT | Token for DNS TXT record verification |
verified_at | TIMESTAMPTZ | When 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 Group | Columns |
|---|---|
| Identity | app_name, app_short_name, tagline |
| Colors | color_primary, color_primary_hover, color_primary_light, color_secondary, color_accent, color_header_bg, color_header_text, color_footer_bg, color_footer_text |
| Assets | logo_url, logo_dark_url, favicon_url, og_image_url |
email_from_name, email_from_address, email_logo_url, email_footer_text | |
| SEO | meta_title, meta_description |
| Features | show_powered_by (boolean), show_pricing_page (boolean) |
| Legal | custom_css, privacy_policy_html, terms_html, privacy_policy_url, terms_url |
tenant_content
Key-value content overrides per tenant (i18n and copy customization).
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Content block ID |
tenant_id | UUID (FK) | References tenants.id |
content_key | TEXT | Dot-notation key (e.g., hero.title) |
content_type | TEXT | text, html, or json |
content | TEXT | The content value |
locale | TEXT | Language code (default en) |
Unique constraint: (tenant_id, content_key, locale).
tenant_admins
Partner admin users with role-based access.
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Admin record ID |
tenant_id | UUID (FK) | References tenants.id |
user_id | UUID (FK) | References auth.users.id |
role | TEXT | admin, manager, or viewer |
Unique constraint: (tenant_id, user_id).
Modified Existing Tables
| Table | Change | Purpose |
|---|---|---|
profiles | Added tenant_id UUID NOT NULL (FK) | Associates each user with a tenant |
credit_packages | Added tenant_id UUID (FK, nullable) | NULL = available to all tenants; specific value = tenant-only |
contact_submissions | Added tenant_id UUID (FK, nullable) | Tracks which tenant a contact form was submitted from |
webhook_events | Added 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:
| Priority | Source | How |
|---|---|---|
| 1 | X-Tenant-ID header | Explicit header set by the frontend after initial config fetch |
| 2 | Origin / Referer hostname | Domain lookup in tenant_domains (verified only) |
| 3 | Authenticated userβs profile | Reads profiles.tenant_id |
| 4 | Default tenant | Fallback 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 Pattern | Value | TTL |
|---|---|---|
tenant-domain:{hostname} | Tenant UUID | 5 min |
tenant-default-id | Default tenant UUID | 5 min |
tenant-config:{tenantId} | Serialized TenantConfig JSON | 5 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:
- Always allows the configured
FRONTEND_URL - In non-production, allows any
localhostorigin - For all other origins, calls
isDomainVerified(hostname)which checkstenant_domainsfor a verified entry - The
X-Tenant-IDheader is included inallowHeaders
Frontend Integration
TenantProvider
File: apps/web/src/lib/tenant-context.tsx
The TenantProvider wraps the app (inside AuthProvider, in layout.tsx) and:
- On mount, reads
window.location.hostname - Fetches
GET /api/tenant/config?domain={hostname} - Stores the
TenantConfigin React context - Applies CSS custom properties to
document.documentElement - 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
| Hook | Returns | Usage |
|---|---|---|
useTenant() | TenantConfig | Access full tenant config (colors, logos, content, etc.) |
useTenantLoading() | boolean | Check if tenant config is still loading |
useTenantContent(key, fallback) | string | Get a tenant content override with fallback |
useTenantJsonContent<T>(key, fallback) | T | Parse 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:
- Checks tenant content overrides first (
tenant.content[key]) - Falls back to base locale strings (
locales/en.json) - 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
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/tenant/config?domain={hostname} | No | Returns full TenantConfig for the given domain |
Super-Admin Routes
Require profiles.role = 'admin'.
| Method | Path | Description |
|---|---|---|
| GET | /api/tenant-admin/tenants | List all tenants (paginated) |
| POST | /api/tenant-admin/tenants | Create a new tenant |
| GET | /api/tenant-admin/tenants/:id | Tenant detail with domains, admins, user count |
| PATCH | /api/tenant-admin/tenants/:id | Update tenant settings |
| GET | /api/tenant-admin/tenants/:id/stats | User count and total credits in circulation |
Tenant Admin Routes
Require tenant_admins membership (or super-admin).
| Method | Path | Description |
|---|---|---|
| GET | /api/tenant-admin/branding | Get tenant branding |
| PUT | /api/tenant-admin/branding | Update branding (colors, logos, emails, legal, etc.) |
| GET | /api/tenant-admin/content | List content overrides |
| PUT | /api/tenant-admin/content/:key | Upsert a content block |
| GET | /api/tenant-admin/users | List users in tenant (paginated, searchable) |
| GET | /api/tenant-admin/stats | Tenant stats |
| GET | /api/tenant-admin/domains | List domains |
| POST | /api/tenant-admin/domains | Add a domain (returns DNS instructions) |
| POST | /api/tenant-admin/domains/:id/verify | Verify domain via DNS TXT record |
| DELETE | /api/tenant-admin/domains/:id | Remove 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" 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
- Tenant admin calls
POST /api/tenant-admin/domainswith{ domain: "pdf.stanford.edu" } - 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}
- CNAME:
- Partner configures DNS records
Verifying a Domain
- Tenant admin calls
POST /api/tenant-admin/domains/:id/verify - API queries Cloudflare DNS-over-HTTPS:
https://cloudflare-dns.com/dns-query?name=_apdf-verify.{domain}&type=TXT - Checks if any TXT record contains the stored
verification_token - On success: marks domain as
verified = true, clears KV cache - 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_idfor webhook reconciliation
Admin UI
Tenant Admin Pages (/tenant-admin/)
| Page | Path | Purpose |
|---|---|---|
| Dashboard | /tenant-admin | User count, conversion stats, links to sub-pages |
| Branding | /tenant-admin/branding | Color pickers, logo URLs, app name, feature flags, live preview |
| Content | /tenant-admin/content | Key-value editor for hero text, features, FAQ, etc. |
| Users | /tenant-admin/users | User list with search and pagination |
| Domains | /tenant-admin/domains | Add/verify/remove custom domains with DNS instructions |
Super-Admin Pages (/admin/tenants/)
| Page | Path | Purpose |
|---|---|---|
| Tenant List | /admin/tenants | All tenants with status, domains, user count |
| Create Tenant | /admin/tenants/new | Wizard: 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 assetstenants/{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
- Super admin navigates to
/admin/tenants/new - Fills in: slug, display name, admin email, (optional) support email, domain, user/page limits
- API creates:
tenantsrowtenant_brandingrow with default colorstenant_domainsrow (if domain provided) with verification token
- Super admin shares DNS instructions with the partner
- Partner configures CNAME + TXT records
- Tenant admin verifies domain via
/tenant-admin/domains - Tenant admin customizes branding and content via
/tenant-admin/brandingand/tenant-admin/content - Users signing up on the partner domain are automatically assigned to the tenant via the
handle_new_user()trigger
Key Files
| File | Purpose |
|---|---|
supabase/migrations/20250222_004_multi_tenancy.sql | Database schema, RLS policies, functions |
packages/shared/src/types.ts | TenantConfig and EmailBranding interfaces |
packages/shared/src/constants.ts | Tenant R2 storage paths |
workers/api/src/middleware/tenant.ts | Tenant resolution middleware + KV caching |
workers/api/src/routes/tenant.ts | Public config endpoint |
workers/api/src/routes/tenant-admin.ts | Admin CRUD, branding, content, domains |
workers/api/src/index.ts | CORS + middleware mounting |
workers/api/src/services/email.ts | White-labeled email templates |
workers/api/src/types/hono-env.ts | Hono context type declarations |
apps/web/src/lib/tenant-context.tsx | TenantProvider + hooks |
apps/web/src/lib/i18n.ts | Translation hook with tenant overrides |
apps/web/src/app/globals.css | CSS custom property defaults |
apps/web/locales/en.json | Base locale strings |