Open-core product for team content management over Git. This repository contains the AGPL-3.0 core. Conversation-first content management — chat with an AI agent to manage structured content.
- Framework: Nuxt 4 (full-stack, single project — NOT monorepo)
- UI: Radix Vue (headless primitives) + Tailwind CSS 4 (CSS-based config, @theme)
- Auth: AuthProvider interface — two impls, shipped as matched pairs:
supabase-auth.ts(Supabase Auth) andmanaged-auth.ts(Supabase-free: jose HS256 JWT + refresh rotation, nuxt-auth-utils OAuth dance, Resend magic link) - Database: DatabaseProvider interface — two impls:
supabase-db/(Supabase PostgreSQL) andpostgres-db/(kysely + pg over any plain PostgreSQL; same RLS policies via therequest.jwt.claim.subGUC contract) - Content engine:
@contentrain/mcp(core/ops + overlay-reader + validator + providers) +@contentrain/types(canonical contracts) +@contentrain/querySDK for UI strings. Studio composesGitHubProviderfrom@contentrain/mcp/providers/githubwith Studio extensions (tree listing, framework detection, PR helpers) — seeserver/providers/git.ts - Icons: Annon custom icon set via @iconify/tailwind4
- Images: @nuxt/image (NuxtImg for all images)
Studio is deployment-flexible. The AGPL core is self-hostable, and the same product model can also be operated as a managed Pro/Enterprise service. All external services are accessed only through provider interfaces. No implementation detail ever leaks into components, pages, composables, or server routes.
Provider interfaces live in server/providers/:
server/providers/auth.ts— AuthProvider interfaceserver/providers/database.ts— DatabaseProvider interfaceserver/providers/git.ts— GitAppProvider interfaceserver/providers/ai.ts— AIProvider interfaceserver/providers/cdn.ts— CDNProvider interfaceserver/providers/media.ts— MediaProvider interfaceserver/providers/email.ts— EmailProvider interfaceserver/providers/connector.ts— ConnectorProvider interfaceserver/providers/deployment.ts— DeploymentProvider interfaceserver/providers/payment/types.ts— PaymentProvider interface
Concrete implementations live alongside interfaces in server/providers/ (single-file [impl]-[service].ts or a directory for multi-module providers):
server/providers/supabase-auth.ts— SupabaseAuthProviderserver/providers/managed-auth.ts— ManagedAuthProvider (jose HS256 JWT + refresh rotation inauth.refresh_tokens, OAuth via nuxt-auth-utils handlers, magic link/invites via Resend)server/providers/supabase-db/— SupabaseDatabaseProviderserver/providers/postgres-db/— PostgresDatabaseProvider (kysely + pg; user-scoped queries viawithUser()=SET LOCAL ROLE authenticated+request.jwt.claim.subGUC)
Provider pairs. Auth and database providers are selected via NUXT_AUTH_PROVIDER (supabase | managed) and NUXT_DATABASE_PROVIDER (supabase | postgres) and only ship as matched pairs — supabase+supabase (default) or managed+postgres. Mixed pairs are rejected at boot by server/plugins/00.validate-config.ts (the Supabase DB provider authorizes RLS queries with Supabase-issued JWTs; managed-auth JWTs only the postgres provider understands). Migrations are one lineage (postgres/migrations/000_auth_shim.sql + supabase/migrations/*.sql) with two runners: Supabase CLI for the supabase pair, scripts/migrate-postgres.mjs (pnpm db:migrate:pg, verify with pnpm db:verify:pg) for plain PG.
Rules — never violate:
- NEVER import
@supabase/supabase-jsoutsideserver/providers/supabase-auth.ts,server/providers/supabase-db/, andserver/providers/supabase-client.ts - NEVER use
useSupabaseClient(),useSupabaseUser(),useSupabaseSession()in components or pages - NEVER use
@nuxtjs/supabaseauto-injected composables outside the adapter layer - NEVER write Supabase-specific SQL (RLS policies,
auth.usersjoins) in application logic — only in migration files - ALL auth checks in server routes use
AuthProvider.getSession(), never the Supabase client directly - Provider instances are resolved via a factory/injection in
server/utils/providers.ts
Studio's own staging/prod runs the managed+postgres pair (Railway PostgreSQL); the Supabase pair remains the AGPL/community default for self-hosters.
Any new provider implementation must satisfy the same interface — zero application code changes required.
Billing runs through a plugin registry at server/providers/payment/.
Each provider ships as a PaymentProviderPlugin (key, label,
isConfigured, create). The registry resolves the active plugin
using a preference order (currently polar → stripe). Billing state
lives on payment_accounts (one active row per workspace); meter
events flow through usage_events_outbox and a drain cron.
Rules — never violate:
- NEVER call Stripe or Polar SDKs outside
server/providers/payment/plugins/<key>.ts - NEVER read/write
payment_accountsorusage_events_outboxoutsideDatabaseProvidermethods - To add a provider: new plugin file + one line in
bootstrapPaymentPlugins(). No other core changes. - To record a usage event: call the typed helper in
server/utils/usage-metering.ts. Don't write to the outbox directly. - AI/API message quotas count credit-weighted units (
shared/utils/ai-credits.ts). Studio chat reserves the turn's whole ceiling atomically (reserveAgentCredits, migration 031), runs the loop against that as a dollar budget (server/utils/turn-budget.ts), and settles once infinallyfrom the real token totals (settleTurnCredits) — refund included, cancel and error included. The Conversation API still reserves 1 and settlescredits - 1. Never meter a studio-funded turn as a flat message, never settle a turn outsidefinally, and never sell overage belowAI_CREDIT_UNIT_USD. - See
docs/PAYMENT_PROVIDERS.mdfor the full setup + extension guide.
- Owner: GitHub OAuth (needs repo access)
- Invited users: Google OAuth or Magic Link (no password)
- Session: h3 useSession() AES-256 encrypted httpOnly cookie, auto-refresh
- Provider factory:
useAuthProvider()singleton viaserver/utils/providers.ts - Two-tier roles:
- Workspace: Owner, Admin, Member
- Project: Editor, Reviewer, Viewer
- Model-specific access:
specificModels+allowedModelson project_members - Chat agent security: permissions in system prompt + API-level enforcement
User → Workspace (billing entity) → Project (connected repo)
- Signup auto-creates personal workspace (type: primary)
- Workspace Owner/Admin → implicit access to all projects
- Workspace Member → needs explicit project_members assignment
- GitHub App installation lives on workspace (not project)
This repository is the AGPL core product surface — no marketing pages. All routes are authenticated:
/auth/login ............... Auth (public)
/auth/callback ............ Auth callback (public)
/ ......................... Workspace list (or redirect to default)
/w/:slug .................. Workspace dashboard — project list
/w/:slug/projects/:id ..... Project workspace (three-panel)
/w/:slug/settings ......... Workspace settings (overview, members, github, ai-keys)
/settings ................. User account settings (profile, account deletion)
Connecting a repository is a dialog (
ConnectRepoDialog.vue), opened from the sidebar, the command palette and the workspace dashboard — not a page. The old/w/:slug/projects/newroute was removed in68345ed; anything still linking to it now falls through to/w/:slug/projects/:idwithid = "new".
/settingspage with tabs: Profile, Account- Profile tab: display name (editable), avatar (read-only from OAuth), email (read-only), connected account badge
- Account tab: danger zone — account deletion guarded by typed-email confirmation (user re-types their own address in the UI; no outbound confirmation email is sent)
- API:
PATCH /api/profile(update displayName),DELETE /api/profile(delete account — CASCADE) - Database:
profilestable via DatabaseProvider (getProfile,updateProfile) - Auth:
AuthProvider.deleteUser()for GDPR account deletion - Sidebar shows
displayName(fallback: email prefix), clickable →/settings
Exact match of old Contentrain CMS tailwind-preset. NEVER violate these rules:
- NO
gray-*— usesecondary-*(= slate) - NO
red-*— usedanger-* - NO
green-*— usesuccess-* - NO
yellow-*/amber-*— usewarning-* - NO
blue-*— useprimary-* - NO
purple-*/indigo-*— useinfo-*
primary(brand blue 50-900)secondary(slate/neutral 50-950) — ALL neutral UIsuccess(green 50-900)warning(amber 50-900)danger(red 50-900)info(purple 50-900)
text-heading— headings, strong text (secondary-900)text-body— body paragraphs (secondary-600)text-muted— helper/secondary text (secondary-400)text-label— form labels (secondary-500)text-disabled— disabled state (secondary-300)border-border— dividers, borders (secondary-200)bg-error— error backgrounds (danger-100)
- Page:
bg-white dark:bg-secondary-950 - Raised/card:
bg-secondary-50 dark:bg-secondary-900 - Input:
bg-white dark:bg-secondary-900
- Default:
border-secondary-200 dark:border-secondary-800
- Heading:
text-secondary-900 dark:text-secondary-100 - Body:
text-secondary-600 dark:text-secondary-300
text-muted,text-label— these are mid-tone and readable on both light/dark
All user-facing text comes from Contentrain dictionary via @contentrain/query SDK:
const { t } = useContent()
t('auth.sign_in_title') // => "Sign in to your account"Dictionary model: ui-strings in .contentrain/content/system/ui-strings/en.json
When adding new UI text:
- Add the key/value to the dictionary via
contentrain_content_saveMCP tool - Run
npx contentrain generateto update SDK client - Use
useContent().t('key')in the component
Atomic design from old Contentrain CMS, refactored for Radix Vue + Tailwind 4:
app/components/
atoms/ — Radix Vue primitives + Tailwind (HeadingText, BaseButton, FormInput, FormLabel, Logo, Avatar, Badge)
molecules/ — Composed atoms (ProviderButtons, AuthLink, EmailButton)
organisms/ — Business logic components (SigninWithProvider, ProfileOverviewPanel, WorkspaceMembersPanel)
Note: No
templates/layer — Nuxt layouts (app/layouts/) handle page-level wrappers.
- All atoms use Radix Vue primitives where applicable
- All components support dark mode
- NuxtImg for all images (not
)
- Icons via
icon-[annon--home-2]class (not inline SVG, except brand logos)
W3C + WCAG compliance for all interactive elements. NEVER violate these rules:
Cursor:
- Global
cursor: pointeris set inmain.css@layer baseforbutton,[role="button"],a[href],summary,select— do NOT addcursor-pointerclass to these elements individually disabled:cursor-not-allowedon disabled interactive elements (already handled in atoms)
Button type attribute:
- ALL
<button>elements MUST have an explicittypeattribute - Default:
type="button"— prevents accidental form submission - Only use
type="submit"inside<form>elements for the primary submit action - BaseButton atom has
typeprop (default:"button") andvariantprop (ghost/primary/danger/secondary)
Focus visibility (keyboard navigation):
- ALL interactive elements MUST have
focus-visiblestyles for keyboard accessibility - Standard pattern:
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 - Add
roundedto inline links/buttons so the focus ring follows the shape - Inputs use
focus:ring(notfocus-visible) — mouse focus indication is correct for form fields
ARIA attributes:
- Inputs with error state:
aria-invalid="true" - Inputs with description text:
aria-describedbylinking to description element - Decorative icons (required asterisk, etc.):
aria-hidden="true" - NEVER use raw
<button>without accessibility attributes in organisms — prefer atom components
Annon icon set (800+ custom icons) via @iconify/tailwind4:
<span class="icon-[annon--home-2] text-muted" />
<span class="icon-[annon--alert-triangle] text-danger-500" />Brand SVGs (GitHub, Google logos) stay as inline SVG — they need exact brand colors.
auth— split panel (form left, marketing right)default— sidebar-first (AppSidebar 240px + full-width main). No header.workspace— three-panel (sidebar 240px, chat, context 400px)
The write path delegates structural operations to
@contentrain/mcp/core/ops instead of implementing file assembly
itself. Every save / delete op composes:
- Studio field validation (
validateContent— re-exported from MCP since Faz S3, shape-aligned so call sites stay untouched). - MCP plan helper (
planContentSave/planContentDelete/planModelSave) — produces a deterministicFileChange[]sorted by path, with canonical JSON (or markdown frontmatter for documents) already serialised viacanonicalStringifyfrom@contentrain/types. - Studio meta override (
applyStudioMetaOverridesincontent-engine/helpers.ts) — replaces MCP'sdefaultMeta(status: 'draft',updated_by: 'contentrain-mcp') with Studio'sautoPublish+ existing-status preservation + per-userupdated_bysemantics. provider.applyPlan({ branch, changes, message, author, base: 'contentrain' })— atomic branch+commit via the GitHub Data API.createBranchis no longer called separately;applyPlanforksbasewhen the branch is missing.context.jsonis NOT part ofchanges— see the context.json invariant below.
context.json lifecycle (MCP 1.5.0 model) — feature branches
never carry context.json. Committing it per-save caused merge
conflicts when parallel cr/* branches landed (each mutated the same
file from the same base). Instead it is regenerated deterministically
on the contentrain branch after a merge, in
branch-ops.ts:mergeBranch → regenerateContextOnContentrain
(buildContextChange over the merged tree + a dedicated
applyPlan commit onto contentrain, best-effort). The seed
context.json is still written once at initProject time. Brain cache
and external readers only ever read it from contentrain, so post-merge
regeneration is the single point it needs to be accurate.
Invariants to preserve when touching this path:
- Content SSOT is the
contentrainbranch. Feature branches always fork from it viaapplyPlan's defaultbase.config.repository .default_branch(main/master) is informational — never the fork point. - Never add
context.jsonto a feature-branchapplyPlan. OnlyinitProject(seed) andregenerateContextOnContentrain(post-merge, oncontentrain) may write it. - Studio's
pinReaderToContentrainwrapper defaults ref toCONTENTRAIN_BRANCHfor every MCP read (MCP's helpers callreader.readFile(path)without a ref). - Scheduling (
publish_at/expire_at) is meta-only and rides on theContentEntry(MCP 3.1.8), never insidedata;nullclears, absent leaves alone, and it never changesstatus—cdn-builder.tsgates delivery of a published entry by it.shapeEntriesForSavelifts any undeclaredpublish_at/expire_atout of entry data so it cannot leak into the content file.applyStudioMetaOverridesstarts from the meta MCP planned (which carries the schedule) and re-owns onlystatus,updated_by,updated_at— rebuilding from the prior file, as it once did, dropped every key the plan had just set. saveModelMERGES with the definition oncontentrain(content-engine/model-merge.ts): omitted fields and top-level keys are kept, a sent field is merged property by property, removal is only viaremoveFields, and a removal/type/kind/i18n change that still has content behind it is refused with the affected count unlessallowBreakingis passed. MCP's owncontentrain_model_save(and so MCP Cloud) still replaces — that is MCP's contract, not Studio's. Field names are snake_case for NEW fields; a legacy name the model already has is kept and reported inwarnings(MCP'slegacyFieldNames/collectFieldPaths, same tolerance ascontentrain_model_save).resolveTitleFieldId(shared/utils/entry-title.ts) falls back with the chain MCP'svalidate --fixbackfills with — name-like keys with required first, then required prose, then prose, never slug/url/code — plus a Studio-only last rung (first field) so a read path renders something. Keep the two in step when either changes.- Locale-agnostic fields (
image/video/file/relation/relations,shared/utils/locale-agnostic-fields.ts) are written to every locale of an i18n model in the same commit (content-engine/locale-fanout.ts), only into entries that already exist there. The schema has nolocalizedflag yet; when@contentrain/typesgrows one, it should replace the type list, not sit beside it. - Media references stay absolute delivery URLs (#321, option 1). Moving a
project to another instance or project id is
POST .../media/rehost(server/utils/media-rehost.ts): a textual rewrite of the old{siteUrl}/api/cdn/v1/{projectId}/media/base across all content files, one commit forked from the read snapshot, refused (409) while any referenced path is missing from this project's storage.copyAssets(same instance, caller must be owner/admin of the SOURCE workspace — project ids are public) copies storage objects and then the library rows (copyMediaAssetRows, one statement). Relative paths (option 2) wait for a resolver in@contentrain/query.
Every agent call (Studio chat chat.post.ts and the ee Conversation API) renders the
prompt in this order, and the order is what makes Anthropic's prompt cache pay:
tools (marker on last tool) → system: static body only (marker) → messages: replayed
history (marker on the last block of the newest kept message) → current user turn:
[<request_context> = content index + UI context + intent + project state, attachments, user text]
- Never put per-request content in
system. Anything that changes between turns (content index, intent, project state, UI context) goes into the user turn viabuildRequestContext(agent-system-prompt.ts). Insystemit would sit in front of every history message and invalidate the conversation cache on every content change. - The composed user turn is persisted byte-identical (
composeUserTurnoutput = seed rowcontent_blocks). Replaying different bytes than the live call shifts the prefix at that message every turn and re-bills the whole previous turn at 2x. The content index rides in the turn only when changed or stale (shouldIncludeContentIndex, refresh every 15 turns) so history doesn't fill with repeated copies. - Breakpoint 4 is the engine's in-turn moving marker (
withInTurnCacheMarker,ITERATION_CACHE_MIN_TOKENS): multi-iteration turns with large tool results re-read their segment at 0.1x instead of re-sending it per iteration. - To hunt a future invalidator, use Anthropic's cache-diagnosis beta
(
cache-diagnosis-2026-04-07,diagnostics.previous_message_id->cache_miss_reason) rather than guessing fromcache_creation_input_tokens. - Every marker is
PROMPT_CACHE_CONTROL(server/providers/ai.ts, 1h TTL). Anthropic requires longer-TTL markers to precede shorter ones — never mix TTLs per call site. buildPromptMessagestrims with hysteresis (HISTORY_TRIM_TARGET). Trimming exactly to the budget every turn would shift the prefix and re-write the whole history each call.- History must replay byte-identically: the Anthropic adapter sorts
tool_use.inputkeys (jsonb does not preserve key order); persisted rows are never rewritten on replay. - Replayed images: base64 → placeholder; URL images older than
RECENT_IMAGE_TURNS→ placeholder with the URL (Anthropic bills URL images by pixels like inline ones). PDFs stay. - The history budget estimator (
estimateContentTokens) is language-aware and bills image blocks at ~1.6K tokens —chars/4under-counted Turkish + images by 3× and let a 48K budget send 150K tokens per call. - Verify on staging with
messages.cache_read_input_tokens: on a warm conversation it should track the whole history;token_count_inputshould stay near the per-turn delta.
Studio boots a real MCP server (@contentrain/mcp/server/http
startHttpMcpServerWith) on a loopback port at Nitro startup
(server/plugins/mcp-cloud-server.ts). The loopback server mounts at
/mcp, so the public client URL is /api/mcp/v1/{projectId}/mcp. The
authenticated public entry is
server/api/mcp/v1/[projectId]/[...slug].ts — Bearer key validation +
project match + api.mcp_cloud plan gate + per-key rate limit (with
Retry-After on 429) + per-key tool allowlist (allowed_tools; empty =
unrestricted) + atomic monthly quota (increment_mcp_cloud_usage_if_allowed)
- usage metering + header strip + proxy to the loopback server +
brain-cache invalidation on write tools. Quota semantics: only
tools/callrequests consume the monthly quota and produce meter events; protocol traffic (initialize, tools/list, SSE GET, session DELETE) is rate-limited but free. Keys live inmcp_cloud_keys(SHA-256 hashed); UI isProjectMcpKeysPanel.vue(project settings → API keys). The whole path is implemented — not a stub.
Scaling note: loopback MCP sessions are in-memory per Nitro instance
(15 min TTL). Multiple instances need sticky sessions for mcp-session-id
affinity; rate limit (Redis) and quota (DB) are already instance-safe.
Media facet (MCP 1.10.0's 5 media tools). The facet is
RepoProvider.media — absent = the tools are hidden from tools/list.
Policy lives at the proxy (runMcpCloudProxy computes mediaEligible
= media stack + plan media.upload + project cdn_enabled + workspace
owner), which injects x-cr-project-id/x-cr-workspace-id/x-cr-media-owner/x-cr-plan
ONLY when eligible; the loopback (mcp-cloud-server.ts) turns those into
buildMcpMediaFacet (server/utils/mcp-media-facet.ts), a passthrough
over the ee media stack (useMediaProvider) that owns SSRF/MIME/size (via
fetchRemoteMedia) + storage-quota + tenant isolation. Key-surface
exception: media tools are never covered by the empty-allowed_tools
"unrestricted" rule — a key needs media_enabled=true or an explicit
listing (else existing keys would silently gain URL ingest + destructive
delete). OAuth advertises media:read/media:write only where the media
stack is configured (advertisedScopes). The tenant fingerprint includes
project id, so an eligibility flip re-inits the session within one request.
Two deliberate boundaries — keep them in mind when changing this path:
-
Reduced tool surface. The loopback server runs against Studio's
GitHubProvider(localWorktree: false, noprojectRoot). So every localWorktree-gated tool —contentrain_merge,contentrain_branch_list,contentrain_branch_delete,contentrain_submit, and alsocontentrain_init,contentrain_scaffold,contentrain_bulk,contentrain_doctor,contentrain_scan,contentrain_apply,contentrain_validatewithfix: true— returns a capability error over MCP Cloud. The actual remote surface is: status, describe, describe_format, content_list, validate (no fix), content/model save+delete. The merge/review lifecycle is Studio-owned. The proxy'sWRITE_TOOL_NAMESis derived from MCP'sTOOL_ANNOTATIONS(readOnlyHint: false) minus the Studio-owned lifecycle tools, so a future MCP release that opens e.g.bulkto remote providers is covered automatically — keep the lifecycle exclusion list intact. -
Role gate at call time. Before a
tools/callreaches the loopback,toolCallDenial(server/utils/mcp-write-gate.ts) checks it against the current role of the person it acts for (resolveMcpActorPermissions: the OAuth grant's user, or an API key'screated_by; no accepted membership = deny). Same rules as the UI routes,content_statusfollowsstatus.patch.tsviacanSetEntryStatus, every message of a batch is read, and it fails closed. A refusal is anisErrortool result and consumes no quota. -
pending-review by contract, judged per call. MCP's remote write path hardcodes
workflowAction: "pending-review"and leaves the merge to Studio. So MCP Cloud writes land ascr/*branches.reconcileMcpCloudWrites(server/utils/mcp-cloud-automerge.ts) judges only the branch the call's result names (git.branch, JSON or SSE), withdecideMergeand the write's real tool, scope and signals, exactly as the UI routes do: auto-merge lands it; on a review project (planworkflow.reviewandconfig.workflow === 'review') the approval policy decides. Other pendingcr/*branches are never swept. The proxy waits up to a deadline and annotates the tool result withmerge.status(merged/held_for_review); past the deadline it still readspending-review.
Medium:
- Branch health: warn/block thresholds (default 50/80, config-driven via
branchWarnLimit/branchBlockLimitsince MCP 1.5.0) + mergedcr/*auto-delete are implemented (branch-health.ts,branch-cleanup.ts). Remaining: surface health status in the UI - Brain cache: no GitHub webhook-triggered invalidation for external pushes (TTL-only, 10min)
- MCP Cloud: integration-test coverage for the proxy route (
/api/mcp/v1/...) and key endpoints is still thin (logic is covered, full HTTP path is not)
Studio runs a trunk-based Git flow. main is the single integration branch and the PR target for all work (contributor, agent, internal). Staging is a Railway deployment environment fed from main, not a long-lived Git branch. This matches the OSS-SaaS norm observed in Grafana, Mastodon, Nextcloud, Supabase, PostHog, Mattermost, cal.com, and most AGPL / open-core peers.
| Branch | Role | Deploy target |
|---|---|---|
main |
Trunk — default, PR target, OSS face, stable-at-HEAD | Railway staging env (studio-staging-5610.up.railway.app) auto |
production |
Deploy pointer — advanced to the tagged commit by release.yml on every stable v* release; no PRs, no human commits |
Railway prod (studio.contentrain.io) auto |
feat/* |
Per-task feature branches | (no auto-deploy) |
fix/* |
Per-task bug-fix branches | (no auto-deploy) |
cr/* |
Contentrain MCP auto-generated content branches | (auto-merged by MCP) |
Optional maintenance branches (release/X.Y, stable-X.Y) may be cut from main for backports to a shipped release line — they are downstream of main, never upstream.
- Every PR targets
main. There is nostaging,develop, ornextbranch in this repo. - Never push directly to
main. All changes go through PR + CI gate. - Release tags (
v*) are cut frommain— the release pipeline advances theproductiondeploy pointer as its final step, which triggers the production deploy. Seedocs/RELEASING.md. - Self-hosters deploy from tagged releases (
v0.1.0,v0.2.0), not frommainHEAD.mainis stable-at-HEAD for CI purposes but tags are the supported deploy contract. - Staging environment ≠ staging branch. Railway deploys every merge to
maininto the staging environment (studio-staging-5610.up.railway.app—staging.contentrain.iois NOT bound) automatically, so the pre-prod verification lives in the deploy pipeline rather than in Git topology. - Commitlint is lenient for MCP auto-commits — messages prefixed with
[contentrain]are ignored by commitlint (seecommitlint.config.ts). Every human commit must obey Conventional Commits.
- Contributor forks, opens a branch from
main - PR targets
main(GitHub's default base, no extra step needed) - Forked PRs run CI but cannot deploy (GitHub secrets are not exposed to forks — intended security boundary)
- Maintainer reviews + merges → Railway auto-deploys to the staging environment
- When ready to ship, a maintainer cuts a version tag (
v*) onmain→ prod deploy via.github/workflows/release.yml
- Conventional Commits enforced by commitlint + husky (MCP
[contentrain]commits ignored) pnpm lint/pnpm lint:fix— @nuxt/eslint with Stylistic (no Prettier)pnpm release— full local release gate (release:check) + changelog/version/tag flow; run frommainon a clean tree- lint-staged on pre-commit (only changed files)
- GitHub Actions CI on every push/PR to
main— two jobs:ci(lint, typecheck, unit+integration+nuxt, RLS + e2e against local Supabase, build) andpostgres-lineage(migration lineage +pnpm db:verify:pg+ contract suite + RLS suite against a plainpostgres:16service)
pnpm test/test:ci— unit + integration + nuxt (DB mocked)pnpm test:rls— RLS contract suite; Supabase local by default, plain PG whenRLS_DB_URLis set (global setup migrates it viascripts/migrate-postgres.mjs)pnpm test:contract— postgres DatabaseProvider against a throwawaypostgres:16(docker run -d -p 54329:5432 -e POSTGRES_PASSWORD=postgres postgres:16;CONTRACT_PG_URLoverrides)pnpm test:e2e— per-file Nuxt boots;app-smokecovers the supabase pair,app-smoke-managedboots the managed+postgres pair (no DB needed — pg connects lazily)
Studio ships as open-core: AGPL-3.0 core + proprietary ee/ directory under a separate license (see ee/LICENSE). The public-facing legal + deployment surface lives in docs/LICENSING.md, docs/EDITIONS.md, and docs/DEPLOYMENT_PROFILES.md — update those when policy changes.
Edition is orthogonal to plan tier: a managed Enterprise customer and a self-hosted Community user see different code paths, even though both models share the free/starter/pro/enterprise tier vocabulary.
- Community Edition =
loadEnterpriseBridge()returnsnull(eitheree/is absent or failed to import). All workspaces resolve to the fixedcommunityplan tier. Numerical limits are unenforced (self-hoster's own infrastructure).requires_eefeatures are hidden/404 regardless of plan. - Enterprise Edition =
loadEnterpriseBridge()returns a bridge instance. Plan tier is determined by the deployment profile (managed/dedicated/on-premise). Full feature set available subject to plan gating.
- ee/ directory is proprietary — NEVER mix
ee/code into core paths. The boundary is enforced by convention + by theEnterpriseBridgeinterface inserver/utils/enterprise.ts. - Core must work without ee/ — every AGPL code path must degrade gracefully when the bridge is
null. Current provider gaps in core (CDN provider, Media provider) are ee-resident by design; do not assume they exist in Community Edition. - Feature gating goes through
hasFeature(plan, feature, { edition })inshared/utils/license.ts. The helper honors both plan tier and therequires_eeflag on the feature row. requires_ee: trueis an AND gate, not OR — even if the plan matrix grants a feature,requires_eefeatures are disabled in Community Edition.- NEVER hardcode plan checks (e.g.
if (plan === 'enterprise')) — always route throughhasFeature()orgetPlanLimit(). - Provider interfaces in core, implementations may be in ee/ — this is the pattern for CDN and Media today.
- UI conditional rendering uses
useDeployment()(edition + plan + billing mode) consistently. Every surface that renders plan/billing/edition info (sidebar, overview, billing tab, usage, members, plan modal, trial banner) reads from the same composable. - Database schema stays in core —
ee/-related columns (e.g.project_members.specific_models) exist in migrations; RLS and application logic keep them unused in Community Edition. - Graceful degradation — bridge-null paths must return sensible defaults (role → editor, metering → no-op, optional features → hidden), never throw 500s.
- NEVER write to
payment_accountsorusage_events_outboxoutsideDatabaseProvider— the abstraction is part of the payment-plugin boundary. - NEVER import Stripe or Polar SDKs outside
server/providers/payment/plugins/<key>.ts.
Under ee/LICENSE scope. Requires an active Contentrain subscription or separately executed agreement.
- Advanced project roles:
reviewer,viewer,specificModels(core degrades toeditor) - BYOA API key management UI + key rotation + encryption (
ai.byoa) - Studio-hosted AI key — the Anthropic key billed to Contentrain
- Conversation API + conversation keys (
api.conversation*) - Outbound webhooks + delivery retry (
api.webhooks_outbound,api.webhooks) - CDN provider implementation (R2 driver, custom variants, preview branches, custom domain)
- Media provider implementation (upload, library, image processing)
- Spam filter (
forms.spam_filter) - Form file upload (
forms.file_upload— depends on media stack) - Form webhook notifications (
forms.webhook_notification— depends on outbound webhooks) - Comment webhook notifications (
comments.webhook_notification) and comment spam filter (comments.spam_filter, roadmap) - SSO (
sso.saml,sso.oidc) — roadmap - White-label branding (
branding.white_label) — roadmap - MCP Cloud custom domain + SSO — roadmap
- Advanced audit log UI (core exposes the read API)
- Approval chains, scheduled publish, premium connectors (Canva, Figma, Recraft, Notion, Google Drive)
Always available, in every edition. Plan-independent.
- All auth flows (both provider pairs: Supabase Auth, and managed auth with OAuth + magic link), workspace/project CRUD, member management (Owner/Admin/Member)
- Chat engine + every deterministic agent tool (content CRUD, validation, branch/merge, brain, search)
- Content CRUD — all 4 kinds (collection, singleton, document, dictionary), all 27 field types
- Auto-merge workflow (cr/* → contentrain → main), review workflow gating via
workflow.review - Content editor modal, all form components, validation, health reporting
- Owner + Admin + Editor workspace roles; Editor project role
- URL fetch connector
- Single + multi-locale (config-driven, not plan-gated)
- Forms core (submission storage, captcha via Turnstile, auto-approve, notifications via Resend)
- Comments v1 (
comments+comment_threadsin the Studio DB — never Git; public read/submit under/api/comments/v1, moderation + moderator replies, WordPress import ofcontentrain-comments@1exports from@contentrain/wp-import; model-levelcommentsconfig lives on the model definition likeform) - AI chat with operator-provided
NUXT_ANTHROPIC_API_KEY
Four profiles, auto-detected from ee/ presence + billing env, overridable via NUXT_DEPLOYMENT_PROFILE:
managed— ee required, subscription-driven plan (contentrain.io)dedicated— ee required, operator-set plan, single-tenant hostedon-premise— ee required, operator-set plan, customer infrastructurecommunity— agpl only, fixedcommunitytier, no billing
See docs/DEPLOYMENT_PROFILES.md for the 12-scenario matrix.
LICENSE-EXCEPTIONS publishes two §7 additional terms for the core: attribution (§7(c)) and no-trademark (§7(e)). /about page and app footer expose the source-code link per §13. Do not remove these without legal review.
- Reselling or embedding
ee/without an executed agreement is prohibited byee/LICENSE§3.3, §3.4. - Commercial SaaS resale of the AGPL core is technically permitted by AGPL-3.0 but the Contentrain trademark cannot be used for the reseller's product identity (
LICENSE-EXCEPTIONS§7(e)). If stronger resale control is needed in the future, the model to evaluate is BSL (Business Source License) migration — this is a roadmap-level decision, not a patch-level change.
Long-form product, marketing, and implementation planning docs are kept outside the tracked repository on purpose.
See ROADMAP.md (project root, git-tracked) for the full public roadmap.
.tmp/contentrain/ contains the old Contentrain headless CMS.
Used as architectural reference for UI patterns, role/permission system, and theme.
NOT part of Studio code — gitignored.
The old CMS UI/UX is approved and is the visual/UX basis for Studio components. You can and should port components/views from the old CMS — but take only the UI/UX, not the code.
What to take from the old CMS:
- Visual layout and spacing
- Component composition and flow (what goes where, in what order)
- User interaction patterns (form flows, button placement, navigation)
What NOT to carry over:
- Code structure, file naming, directory organization
- Old component/variable naming conventions
- Code quality issues, anti-patterns, TODOs, hacks
- Pinia store logic, Vue Router usage, Firebase/LuiButton imports
- Hardcoded strings
How to port:
- Rewrite from scratch using the old CMS as a visual reference
- Follow Studio's atomic structure: atoms → molecules → organisms
- Apply
skills-lock.json(antfu) standards — linting, naming, code style enforced automatically - Replace all tech references: Vue Router → NuxtLink, LuiButton → Radix atoms, Firebase → AuthProvider, hardcoded strings →
t('key'), raw Tailwind colors → Studio palette,<img>→<NuxtImg>
Key auth paths to reference:
.tmp/contentrain/client/src/
layouts/auth.vue → app/layouts/auth.vue
components/organisms/SigninWithProvider.vue → app/components/organisms/SigninWithProvider.vue
components/organisms/SigninWithEmail.vue → app/components/organisms/SigninWithEmail.vue
pages/auth/signin.vue → app/pages/auth/login.vue
atoms/ molecules/ → app/components/atoms/ molecules/