Skip to content

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:

FeatureModule
CDN content deliveryee/cdn/
Media processing (Sharp)ee/media/
BYOA API key managementee/enterprise/
Outbound webhooksee/enterprise/
Conversation APIee/enterprise/
Advanced roles (reviewer, viewer, specificModels)ee/enterprise/
CDN usage meteringee/enterprise/
SSO (SAML, OIDC)Future
Approval chains, scheduled publishFuture
Audit log, activity feedFuture
White-label brandingFuture
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:

ts
// 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 keyPlans that grant itrequires_ee
ai.byoapro, enterpriseYes
cdn.deliverystarter, pro, enterpriseYes
workflow.reviewcommunity, starter, pro, enterpriseNo
roles.specific_modelspro, enterpriseYes
api.conversationpro, enterpriseYes
sso.samlenterpriseYes

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

ts
// 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
PlanPriceDescription
Free$0Structural signup shell. Browse the dashboard; 0 AI messages, no media or project connection until a trial starts.
Starter$9/moFull platform access. Git, projects, chat (Haiku), media, forms, CDN.
Pro$49/moAdvanced features. Custom variants, spam filter, specific models, BYOA.
EnterpriseCustomEverything. 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:

ts
// 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:

ts
// 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:

ts
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:

ts
// 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:

FeatureDegradation
CDN deliveryuseCDNProvider() returns null, CDN routes return 404
Media uploaduseMediaProvider() returns null, upload returns error
AI key managementRoutes return 403
WebhooksRoutes return 403, emitWebhookEvent is a no-op
Conversation APIRoutes return 403
Reviewer roleDowngrades to editor (can still read/write, just no review separation)
Viewer roleDowngrades to editor
Specific modelsspecificModels 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

  1. Provider interfaces live in core. Implementations can be in ee/.
  2. Database schema stays in core. Enterprise columns exist but are unused/RLS-gated in free tier.
  3. UI conditional rendering uses hasFeature() in computed properties.
  4. Core must work without ee/. Run the full test suite with ee/ removed.
  5. Never mix ee/ code into core. No imports from ee/ in server/ or app/.

Adding a New Enterprise Feature

  1. Define the feature key in FEATURE_MATRIX (e.g., 'audit.log')
  2. Add the bridge method to EnterpriseBridge interface in server/utils/enterprise.ts
  3. Create the implementation in ee/enterprise/
  4. Create a thin route in server/api/ that delegates via runEnterpriseRoute
  5. Add UI gating in components: v-if="hasFeature(plan, 'audit.log')"

Testing Enterprise Features

ts
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 in LICENSE-EXCEPTIONS -- attribution §7(c) and no-trademark §7(e)) and the proprietary ee/LICENSE for the Enterprise Edition
  • Deployment info: the resolved profile, edition, and billingMode

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.

Released under the AGPL-3.0 License.