Enterprise Edition
Contentrain Studio follows an Open Core model. The core product is licensed under AGPL-3.0 and is a fully functional CMS. The ee/ directory contains proprietary enterprise features under a separate license.
Directory Structure
studio/
├── app/ # AGPL — core application
├── server/ # AGPL — server routes, providers, utils
├── shared/ # AGPL — shared code (license system)
├── ee/ # Proprietary — enterprise features
│ ├── LICENSE # Proprietary license
│ ├── README.md # Enterprise module documentation
│ ├── cdn/ # Cloudflare R2 CDN provider
│ ├── media/ # Sharp image processing
│ └── enterprise/ # Bridge + route handlers
└── ...Core vs Enterprise Boundary
What Stays in Core (AGPL)
These features work without ee/:
- All auth flows (GitHub OAuth, Google OAuth, Magic Link)
- Workspace and project CRUD
- Chat engine and all agent tools
- Content CRUD (all 4 kinds, all 27 field types)
- Content Engine (validate, serialize, branch, commit)
- Two-step merge workflow (
cr/*->contentrain->main) - Content editor modal and all UI components
- Owner and Editor roles
- URL fetch connector
- Single and multi-locale (config-driven, not plan-gated)
- All provider interfaces
- Feature flag system
What Belongs in ee/
These require the enterprise bridge:
| Feature | Module |
|---|---|
| CDN content delivery | ee/cdn/ |
| Media processing (Sharp) | ee/media/ |
| BYOA API key management | ee/enterprise/ |
| Outbound webhooks | ee/enterprise/ |
| Conversation API | ee/enterprise/ |
| Advanced roles (reviewer, viewer, specificModels) | ee/enterprise/ |
| CDN usage metering | ee/enterprise/ |
| SSO (SAML, OIDC) | Future |
| Approval chains, scheduled publish | Future |
| Audit log, activity feed | Future |
| White-label branding | Future |
| Premium connectors (Canva, Figma, Notion) | Future |
Feature Flag System
All feature gating uses hasFeature() -- never hardcode plan checks.
Single Source of Truth
FEATURE_MATRIX is not a hardcoded literal. It is derived at build time from the .contentrain/ content layer — specifically system/plan-features/data.json — so editing a feature row in Contentrain (via MCP) changes gating with no code edit:
// shared/utils/license.ts
import planFeaturesData from '../../.contentrain/content/system/plan-features/data.json'
export interface FeatureMatrixEntry {
plans: StudioPlan[] // which plan tiers grant the flag
requires_ee: boolean // needs the enterprise bridge to function
roadmap: boolean // announced but not yet GA
}
// Built by iterating plan-features rows of type 'feature':
export const FEATURE_MATRIX: Record<string, FeatureMatrixEntry> = (() => {
const matrix: Record<string, FeatureMatrixEntry> = {}
for (const row of Object.values(planFeatures)) {
if (row.type !== 'feature') continue
matrix[row.key] = {
plans: PLAN_SLUGS.filter(plan => valueForPlan(row, plan) === 'true'),
requires_ee: row.requires_ee === 'true',
roadmap: (row.roadmap ?? 'false') === 'true',
}
}
return matrix
})()Content IDs vs. feature keys
In data.json each row's entry ID is hyphenated (ai-byoa, roles-specific-models), but its key field — the identifier hasFeature() uses — is dotted (ai.byoa, roles.specific_models). Always gate against the dotted key, e.g. hasFeature(plan, 'roles.specific_models').
A few entries derived from the current data.json:
| Feature key | Plans that grant it | requires_ee |
|---|---|---|
ai.byoa | pro, enterprise | Yes |
cdn.delivery | starter, pro, enterprise | Yes |
workflow.review | community, starter, pro, enterprise | No |
roles.specific_models | pro, enterprise | Yes |
api.conversation | pro, enterprise | Yes |
sso.saml | enterprise | Yes |
community is the AGPL-only self-host tier; it appears in a feature's plans list only for capabilities that work without the enterprise bridge (requires_ee: false). The matrix layer never filters requires_ee itself — that gate is applied by hasFeature() when the caller supplies the runtime edition.
Usage
// Server-side
import { hasFeature, getWorkspacePlan, getPlanLimit } from '~/server/utils/license'
const plan = getWorkspacePlan(workspace)
if (!hasFeature(plan, 'media.upload')) {
throw createError({ statusCode: 403, message: 'Upgrade required' })
}
const limit = getPlanLimit(plan, 'forms.submissions_per_month')Plan Hierarchy
free < starter < pro < enterprise| Plan | Price | Description |
|---|---|---|
| Free | $0 | Structural signup shell. Browse the dashboard; 0 AI messages, no media or project connection until a trial starts. |
| Starter | $9/mo | Full platform access. Git, projects, chat (Haiku), media, forms, CDN. |
| Pro | $49/mo | Advanced features. Custom variants, spam filter, specific models, BYOA. |
| Enterprise | Custom | Everything. SSO, white-label, custom CDN domain. |
Self-Hosted Plan Resolution
getWorkspacePlan resolves a workspace's effective plan from the deployment profile, not from a payment-key check. resolveDeployment().planSource decides where the plan comes from:
// server/utils/license.ts
export function getWorkspacePlan(workspace: { plan?: string | null }): Plan {
const d = resolveDeployment()
switch (d.planSource) {
case 'fixed': // Community Edition → fixed `community` tier
return d.fixedPlan ?? d.defaultPlan
case 'operator': // on-prem / dedicated → operator sets workspace.plan
return normalizePlan(workspace?.plan ?? d.defaultPlan)
case 'subscription': // managed service → billing webhooks sync workspace.plan
return normalizePlan(workspace?.plan)
}
}A self-hosted Community Edition deployment uses planSource: 'fixed' and resolves every workspace to the community tier — the AGPL-only self-host tier — rather than the old behavior of forcing starter. Because the enterprise bridge is absent, the edition is agpl and every requires_ee feature is force-disabled regardless of tier.
Enterprise Bridge
The bridge pattern connects core code to enterprise implementations at runtime:
// server/utils/enterprise.ts
interface EnterpriseBridge {
// Route handlers
listWorkspaceAiKeys: EnterpriseRouteHandler
createWorkspaceAiKey: EnterpriseRouteHandler
listProjectWebhooks: EnterpriseRouteHandler
createProjectWebhook: EnterpriseRouteHandler
handleConversationApiMessage: EnterpriseRouteHandler
// ... more route handlers
// Provider factories
createCDNProvider?(config: R2Config): CDNProvider
createMediaProvider?(config: { cdn: CDNProvider, db: DatabaseProvider }): MediaProvider
// Utility functions
trackCDNUsage?(projectId: string, apiKeyId: string, bytes: number): Promise<void>
emitWebhookEvent?(projectId: string, workspaceId: string, event: string, data: Record<string, unknown>): Promise<void>
normalizeProjectMemberAccess?(input: RoleInput): EnterpriseProjectMemberAccess
resolveChatApiKey?(input: KeyInput): Promise<{ apiKey: string, usageSource: string } | null>
}Loading the Bridge
The bridge is loaded lazily via dynamic import:
async function loadEnterpriseBridge(): Promise<EnterpriseBridge | null> {
try {
const mod = await import('../../ee/enterprise')
return mod.createEnterpriseBridge()
} catch {
return null // ee/ not present — graceful null
}
}Using the Bridge in Routes
Core routes delegate to enterprise handlers:
// server/api/workspaces/[workspaceId]/ai-keys/index.get.ts
import { runEnterpriseRoute } from '~/server/utils/enterprise'
export default defineEventHandler(event =>
runEnterpriseRoute('listWorkspaceAiKeys', 'aikeys.upgrade', event)
)If the bridge is not loaded, runEnterpriseRoute throws a 403 error.
Graceful Degradation
When ee/ is absent, the following behavior applies:
| Feature | Degradation |
|---|---|
| CDN delivery | useCDNProvider() returns null, CDN routes return 404 |
| Media upload | useMediaProvider() returns null, upload returns error |
| AI key management | Routes return 403 |
| Webhooks | Routes return 403, emitWebhookEvent is a no-op |
| Conversation API | Routes return 403 |
| Reviewer role | Downgrades to editor (can still read/write, just no review separation) |
| Viewer role | Downgrades to editor |
| Specific models | specificModels treated as false (full model access) |
TIP
Degradation is always safe -- it never causes errors or data loss. Features become unavailable, not broken.
Building Enterprise Features
Rules
- Provider interfaces live in core. Implementations can be in
ee/. - Database schema stays in core. Enterprise columns exist but are unused/RLS-gated in free tier.
- UI conditional rendering uses
hasFeature()in computed properties. - Core must work without
ee/. Run the full test suite withee/removed. - Never mix
ee/code into core. No imports fromee/inserver/orapp/.
Adding a New Enterprise Feature
- Define the feature key in
FEATURE_MATRIX(e.g.,'audit.log') - Add the bridge method to
EnterpriseBridgeinterface inserver/utils/enterprise.ts - Create the implementation in
ee/enterprise/ - Create a thin route in
server/api/that delegates viarunEnterpriseRoute - Add UI gating in components:
v-if="hasFeature(plan, 'audit.log')"
Testing Enterprise Features
import { setEnterpriseBridgeForTesting } from '~/server/utils/enterprise'
// Inject a fake bridge for testing
setEnterpriseBridgeForTesting({
listWorkspaceAiKeys: async () => [],
// ... mock other methods
})Source Availability & the About Page (AGPL §13)
The AGPL-3.0 core satisfies the network-use source-offer requirement of AGPL §13 through a public, unauthenticated /about page (plus a source link in the app footer). Every deployment exposes:
- A link to the source repository (
github.com/Contentrain/studio) - A modified-source note -- if an operator changes the core, §13 requires offering that modified source to users interacting with it over the network
- The license files: the core under AGPL-3.0 (
LICENSE+ the §7 additional terms inLICENSE-EXCEPTIONS-- attribution §7(c) and no-trademark §7(e)) and the proprietaryee/LICENSEfor the Enterprise Edition - Deployment info: the resolved
profile,edition, andbillingMode
Self-hosters
If you deploy a modified core, keep /about reachable and point it at your modified source per §13. Do not remove the source link or the §7 attribution / no-trademark terms without legal review.
Related Pages
- Architecture -- provider pattern and system design
- Roles & Permissions -- how roles are gated by plan
- Provider Interfaces -- interface definitions
- Self-Hosting -- deploying without enterprise features