Skip to content

Types ​

npm version npm downloads

@contentrain/types is the shared type contract for the Contentrain ecosystem. Every package — MCP, CLI, SDK, Rules — imports its domain types from here instead of redefining them. If you are building tooling on top of Contentrain or authoring a framework integration, this is the package you depend on.

Why a Shared Types Package? ​

Without a single source of truth, each package would define its own ModelDefinition, FieldDef, or ContentrainConfig — and they would inevitably drift. @contentrain/types ensures:

  • One vocabulary — every package speaks the same domain language
  • Breaking changes are visible — a type change here is an ecosystem-level change
  • Zero runtime cost — most exports are type-only, tree-shaken away in production

Ecosystem Role

  • MCP validates and writes ModelDefinition
  • CLI reads ContentrainConfig and ContextJson
  • SDK codegen consumes ModelDefinition and FieldDef
  • Rules align with the same model and workflow vocabulary
  • Contentrain Studio operates on the same type contract — schemas defined locally work identically in team workflows

Install ​

bash
pnpm add @contentrain/types

For type-only usage (no runtime exports needed):

bash
pnpm add -D @contentrain/types

Requirements:

  • Node.js 22+
  • TypeScript 5.0+

Quick Example ​

ts
import type {
  ContentrainConfig,
  FieldDef,
  ModelDefinition,
  ValidationResult,
} from '@contentrain/types'

const fields: Record<string, FieldDef> = {
  title: { type: 'string', required: true },
  slug: { type: 'slug', required: true, unique: true },
}

const model: ModelDefinition = {
  id: 'blog-post',
  name: 'Blog Post',
  kind: 'collection',
  domain: 'blog',
  i18n: true,
  title_field: 'title',
  fields,
}

const config: ContentrainConfig = {
  version: 1,
  stack: 'next',
  workflow: 'review',
  locales: { default: 'en', supported: ['en', 'tr'] },
  domains: ['blog'],
}

const result: ValidationResult = {
  valid: true,
  errors: [],
}

Export Catalog ​

Core Unions ​

TypeValuesReference
FieldType27 field types (string, number, boolean, relation, ...)Field Types
ModelKindsingleton, collection, document, dictionaryModel Kinds
ContentStatusdraft, in_review, published, rejected, archived
ContentSourceagent, human, import
WorkflowModeauto-merge, reviewConfiguration
StackTypenuxt, next, astro, sveltekit, remix, + 25 moreConfiguration
Platformweb, mobile, api, desktop, static, other
ContextSourcemcp-local, mcp-studio, studio-ui
CollectionRuntimeFormatmap, array
LocaleStrategyfile, suffix, directory, none
FileFrameworkvue, svelte, jsx, astro, script

Core Interfaces ​

InterfacePurpose
FieldDefField schema definition (type, required, unique, constraints)
ModelDefinitionFull model schema (id, kind, domain, fields, i18n, locale coverage, locale strategy)
ModelLocaleScopeThe locales one model is checked against, plus whether that list came from the model (locales) or the project
ContentrainConfigProject configuration (stack, workflow, locales, domains)
VocabularyShared terms for content consistency
EntryMetaPer-entry metadata (status, source, timestamps)
AssetEntryAsset registry entry (path, type, size, alt)
ValidationErrorStructured validation issue — severity is error, warning, or notice (notices flag drift like drafts beside published entries)
ValidationResultValidation outcome (valid flag + error list)
ContextJsonLast operation context written by MCP
ModelSummaryLightweight model info for listing operations

Provider Contract Types ​

Third-party developers can implement custom providers by implementing these interfaces:

Interface / TypePurpose
RepoProviderFull provider contract: read, write, branch, merge, diff operations, plus optional media?: MediaProvider, getMergeBase? and createMergeCommit? (reconcile) members
RepoReaderRead-only interface (readFile, listDirectory, fileExists)
RepoWriterWrite interface (applyPlan for atomic commits)
ProviderCapabilitiesCapability flags (localWorktree, sourceRead, sourceWrite, pushRemote, branchProtection, pullRequestFallback, astScan, optional mergeCommit)
FileChangeA single file addition, modification, or deletion ({ path, content: string | null })
ApplyPlanInputInput for a single atomic commit (branch, changes, message, author, optional base)
CommitResult of a commit operation (sha, message, author, timestamp)
BranchGit branch metadata (name, sha, protected)
FileDiffFile change within a plan (path, status, before, after)
MergeResultMerge outcome (merged flag, sha, pullRequestUrl, optional sync?: SyncResult for LocalProvider, optional remote? source-branch cleanup outcome)
SyncResultSelective file sync result (synced, skipped, optional warning)
BaseAdvance'advanced' | 'blocked_diverged' — what happened to the base branch after a write (shared vocabulary with Studio; a PR is an attachment, never a third state)
RemotePush'pushed' | 'rejected' | 'no-remote' | 'disabled' — outcome of pushing the contentrain branch; 'disabled' = local mode (git.push: false or CONTENTRAIN_NO_PUSH=1), nothing was pushed
ConflictItemOne surviving reconcile conflict — position (path, key, field, locale), the three values, a CLOSED code union (Studio keys localized editor questions on it), and a value-derived id
ConflictCodeClosed union of conflict kinds — adding a value is a minor + changelog entry; renaming or removing one is breaking
ConflictResolutionA decision keyed by conflict id: { id, choose: 'ours'|'theirs' } or { id, value } — stale ids (values changed since the dry-run) are dropped and re-reported
CommitAuthorCommit author metadata (name, email)

Media facet types (implemented by providers exposing a media stack — drives the contentrain_media_* tools):

Interface / TypePurpose
MediaProviderOptional RepoProvider.media facet: list / get / ingest / update / delete
MediaAssetOne asset — id, path (media/...), optional url, mime, size, alt, tags, createdAt, meta
MediaListOptionsList filters (search, tag, limit, cursor)
MediaListResultList page (assets, optional nextCursor, total)
MediaIngestInputURL-based ingest input (url, optional filename, alt, tags)
MediaUpdateInputMetadata patch (alt, tags, filename)

Pre-built capability set:

  • LOCAL_CAPABILITIES — Capability set for LocalProvider: localWorktree, sourceRead, sourceWrite, pushRemote, astScan and mergeCommit enabled; branchProtection and pullRequestFallback are false (a local worktree has no remote protection or PR flow). Exported from @contentrain/types for custom providers that back onto the local filesystem.

See RepoProvider Reference for the complete interface definitions and a minimum-viable provider recipe.

Storage Types ​

These types define the canonical JSON structure for each model kind on disk:

TypeModel KindShape
SingletonContentFileSingletonRecord<string, unknown>
CollectionContentFileCollectionRecord<string, Record<string, unknown>> (object-map by entry ID)
DictionaryContentFileDictionaryRecord<string, string> (flat key-value, all strings)

Output Types ​

How MCP and SDK return content to consumers (different from storage format):

TypeDescription
CollectionEntry{ id: string } & Record<string, unknown>
CollectionContentOutputCollectionEntry[] (array format)
DocumentEntry{ slug, frontmatter, body } — parsed markdown
DocumentContentOutputDocumentEntry[]
PolymorphicRelationRef{ model, ref } — cross-model relation storage

Metadata Types ​

TypeDescription
SingletonMetaAlias for EntryMeta
CollectionMetaRecord<string, EntryMeta> — per-entry metadata map
DocumentMetaAlias for EntryMeta
DictionaryMetaAlias for EntryMeta

Scan & Graph Types ​

Used by the normalize flow (scan, extract, reuse):

TypePurpose
ScanCandidateHardcoded string candidate with file, line, column, context
DuplicateGroupGroup of repeated strings with occurrence locations
GraphNodeFile node in the project graph (category, imports, strings)
ProjectGraphFull project structure graph (pages, components, layouts)
ScanCandidatesResultScan output with candidates, duplicates, and stats
ScanSummaryResultHigh-level scan summary (directory breakdown, top repeated)
StringContextWhere a string appears (jsx_text, template_attribute, ...)
FileCategoryFile classification (page, component, layout, other)
NormalizePlanNormalize plan exchanged between scan and apply
NormalizePlanModelModel proposal inside a normalize plan
NormalizePlanExtractionOne extraction target (content entry to create)
NormalizePlanPatchOne source patch inside a normalize plan

Runtime Constants ​

Beyond types, the package ships a small runtime surface: constants plus pure, dependency-free validate/serialize functions (browser-compatible — Studio shares the same validation contract through them). Constants first:

ts
import {
  CONTENTRAIN_DIR,       // '.contentrain'
  CONTENTRAIN_BRANCH,    // 'contentrain'
  PATH_PATTERNS,         // Canonical file path patterns
  SLUG_PATTERN,          // /^[a-z0-9]+(?:-[a-z0-9]+)*$/
  ENTRY_ID_PATTERN,      // /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/
  LOCALE_PATTERN,        // /^[a-z]{2}(?:-[A-Z]{2})?$/
  CANONICAL_JSON,        // { indent: 2, encoding: 'utf-8', ... }
} from '@contentrain/types'
ConstantValuePurpose
CONTENTRAIN_DIR'.contentrain'Root directory name
CONTENTRAIN_BRANCH'contentrain'Dedicated content branch name
PATH_PATTERNSObjectCanonical paths for config, models, content, meta
SLUG_PATTERNRegExpValidates slug format
ENTRY_ID_PATTERNRegExpValidates entry IDs
LOCALE_PATTERNRegExpValidates ISO locale codes
CANONICAL_JSONObjectDeterministic serialization rules
RESERVED_PATHSreadonly string[]Four .contentrain/ files this repository claims but does not yet write — see Reserved paths
SECRET_PATTERNSReadonlyArray<RegExp>Provider-shaped patterns behind detectSecrets — extend for custom secret detection. The generic api_key = … rule is not in this list: it fires only when looksLikeCredential accepts the captured tail

Runtime Functions ​

Validate functions (pure, dependency-free):

FunctionPurpose
validateSlug(slug)Kebab-case slug validation
validateEntryId(id)Entry ID format validation
validateLocale(locale, config)Locale format + config support check
resolveModelLocales(model, config)The locales a model's content is expected to cover — model.locales when it declares one, otherwise config.locales.supported (or the default locale alone when i18n: false). Returns { locales, source }
describeModelLocaleScope(scope)Names a resolved scope the way a validation message should quote it
validateModelLocales(model, config)Checks a locales declaration is a subset of config.locales.supported — no duplicates, not empty, no unsupported locale
detectSecrets(value)Detect potential secrets in field values
looksLikeCredential(tail)Whether a value assigned to an API-key setting reads as a credential (has a digit, mixes letters and digits in a token) rather than a placeholder or setting name
validateFieldValue(value, fieldDef)Full field schema validation (type, required, min/max, pattern, select)
validateSemanticType(value, type)Semantic checks for typed values (integer, date, email, url, ...)
validateAccept(value, accept)Extension-based accept constraint check for media paths
isMediaType(type)Whether a field type is media-backed

Serialize functions (pure, dependency-free):

FunctionPurpose
sortKeys(obj, fieldOrder?)Recursive key sorting for canonical output
canonicalStringify(data, fieldOrder?)Deterministic JSON serialization
generateEntryId()12-char hex entry ID generation
parseMarkdownFrontmatter(content)Parse YAML frontmatter + body from markdown
serializeMarkdownFrontmatter(data, body)Serialize data + body into markdown frontmatter
parseFrontmatterScalar(raw)One frontmatter scalar: booleans, null, numbers, quoted strings with escapes decoded
parseFrontmatterScalarString(raw)The same, always as a string — a SKU of "007" must not become 7
splitFrontmatterList(inner)Split an inline array on commas outside quotes

Execution/approval functions (pure; computePlanHash uses Web Crypto):

FunctionPurpose
riskRank(risk)Position on the risk ladder; higher is more severe
highestRisk(risks)The worst class in a list — how a multi-step plan is rated
isTerminalRunStatus(status)Whether a run will move on its own
isReservedPath(path)Whether a path is one of RESERVED_PATHS
approversFor(receipt, gate)Approvers recorded on a receipt for one gate
planHashPayload(plan)The exact canonical-JSON bytes plan_hash covers
computePlanHash(plan)Promise<string> — SHA-256 of that payload, lowercase hex
effectiveRisk(plan)The class a plan is judged at — its own, or its worst step's
requiredApprovals(plan, policy?)What a policy demands of a plan, before any decision
evaluateApproval(input)May this proceed? Requirements, who met them, and why a decision did not count

Unique constraints and relation references need external state (all entries / target existence), so they stay in MCP's validator — validateFieldValue covers everything schema-level.

Git Transaction Types ​

TypePurpose
SyncResultResult of selective file sync (synced files, skipped files, warning)
ContentrainErrorStructured error with code, message, agent hint, and developer action
ScaffoldTemplateTemplate definition for project scaffolding

Frontmatter Round Trip ​

A document's fields live in YAML frontmatter, and two readers open them: the content engine through parseMarkdownFrontmatter (re-exported by @contentrain/mcp), and @contentrain/query's client generator and Astro loader. The property both depend on is that a value written and read back is the same value.

It did not hold

ValueCame back as
He said "Hi"He said \"Hi\" — quotes stripped without decoding the escapes
C:\path\toC:\\path\\to, doubling again on every further save
line one⏎line twoline one — the rest was written as lines the reader skipped
padded padded
'42' (a string)42 (a number)
true (a boolean)'true' (a string)

The guarantee is now explicit: for every value serializeMarkdownFrontmatter can write, parseMarkdownFrontmatter returns it unchanged, and a second round trip produces identical bytes. The second trip is part of the test on purpose — backslash doubling only diverges on the trip after the one that introduced it, so a single-trip test passes on content that corrupts a little more with every export.

  • A quoted scalar's escapes are decoded (\\, \", \n, \r, \t, \uXXXX). An unrecognised escape keeps its backslash rather than erroring: hand-written frontmatter says "C:\Users". Text that merely starts and ends with a quote ("a" and "b") is not treated as one scalar.
  • A value carrying a newline, tab, backslash or edge whitespace is quoted and escaped, so it occupies one line and no part of it is silently dropped.
  • A string that would read back as another type is quoted; a real boolean, number or null is not, so each reads back as itself.
  • An empty array is written key: []. A bare key: is genuinely ambiguous — empty array, empty object, or null — and the two readers guessed differently.

The scalar grammar is exported and imported by the SDK reader rather than replicated. That is the actual fix: when each side had its own copy, correcting one would have turned a shared bug into a silent disagreement between the generated client and the content engine. A parity suite in @contentrain/query asserts both readers return the same values for the same bytes.

Body text keeps its internal blank lines; only leading and trailing whitespace is normalised, which is markdown behaviour rather than loss.

Execution & Approval Contracts ​

@contentrain/types is the contract layer for operations, not just for content shapes. The migration engine produces plans and receipts, Studio renders the plan card and collects approvals, and MCP is where a plan's steps run. If each defined its own RiskClass, "destructive" would mean three different things and the approval guarding it would be theatre.

Risk and approval ​

ts
import { RISK_CLASSES, highestRisk } from '@contentrain/types'

// A survey that ends in a deploy is a deploy.
highestRisk(['read_only', 'bulk_content', 'deployment'])  // 'deployment'

RiskClass is an ordered ladder — read_only → low_risk_content → bulk_content → destructive_schema → external_effect → financially_material → deployment — and a policy written for one rung is expected to cover everything above it.

ApprovalGate keeps three questions separate: plan (before the work starts, on scope and cost), change (on the diff the agent produced), and release (on production effect). Approving what will be done is not approving what was produced, and neither is permission to publish it.

TypePurpose
ApprovalRule, ApprovalPolicyFile.contentrain/approval-policies.json — which risk needs whose approval, in which mode (auto / single / quorum). Lives in git beside the content it governs, so the policy in force is the policy on the branch. Rules are additive: a policy file can only make a project stricter
ApprovalRequirementAn outstanding demand, carrying because — the risk class of the rule that produced it — so a UI can say why a gate appeared
ApprovalGrantA decision actually given, bound to an exact plan_hash. Change the plan and its grants stop applying
ActorRefWho is acting. kind (human / agent / system) is load-bearing: an agent may never approve its own work

Plans and receipts ​

TypePurpose
ExecutionPlanAn operation fully described before it runs: steps, union scope, risk, estimate, rollback, assumed repository state
ExecutionStepOne tool invocation, with its own risk and scope
ExecutionScopeWhat is touched — models, locales, entries, routes, files, assets, providers, external domains. An absent field means "none", not "unknown"
ExecutionEstimate / ExecutionCostCost metered before it is priced: tokens, duration_ms, items (a count), bytes_stored (at rest), bytes_out (moved). Storage and egress are separate fields because they are billed by different rates
ExecutionReceiptWhat happened: status, approvals, checkpoints, verification, measured cost, and the scope actually touched — the same ExecutionScope shape, so prediction and outcome can be subtracted
RollbackPlanThe undo as a command, not a promise. available: false tells the approver before deciding
RunStatusdraft → planned → awaiting_approval → approved → scheduled → queued → running → verifying → completed, plus the interrupted states
DeploymentTargetWhere a build is published. Carries a secret_ref, never a secret — this document is written to git
AutomationDefinitionReserved shape for .contentrain/automations.json; nothing reads it yet

The evaluator ​

ts
import { evaluateApproval, requiredApprovals } from '@contentrain/types'

// For the plan card, before anyone has decided:
requiredApprovals(plan, policy)
// → [{ gate: 'release', mode: 'quorum', min_approvals: 2, because: 'deployment' }]

// At the gate:
const decision = evaluateApproval({ plan, policy, grants, commit_sha, now })
decision.allowed          // every requirement met and the plan has not expired
decision.outstanding      // what is still missing, with who has signed so far
decision.rejected_grants  // decisions that did not count, each with a reason
decision.reasons          // one line per blocker, written for a person

It replaces a role check. Asking "is this person an owner?" cannot express "a bulk publish needs a second pair of eyes even from the owner", and cannot tell a typo fix from a domain cutover. The evaluator asks about the action instead: its risk, its scope, and what the project's policy says about that combination.

RuleWhy
A plan cannot understate itselfeffectiveRisk() takes the worst of the plan's declared class and its steps', so a plan labelled read_only carrying a deploy step is evaluated as a deploy
Each matching rule is its own requirement, all must be metMerging two rules needs a way to combine modes, roles and counts — and every such rule has a case where the result is looser than one of its inputs
auto does not climb the ladderEvery other mode covers its class and everything above it. If auto did too, one auto rule on a low rung would exempt every heavier operation above it
An agent never approvesNot its own work, not anyone's. A plan's author cannot approve it either unless the project sets allow_self_approval — which does not extend to agents
A change decision is about a diffPresented with a different branch tip than the one reviewed, it does not count
now is an inputNothing reads the clock, so a blocked run can be explained months later by replaying the same arguments

Every rejected decision carries a machine-readable GrantRejection reason — plan_hash_mismatch, commit_mismatch, expired, agent_approver, self_approval, role_not_permitted, duplicate_approver, no_matching_requirement — because the useful question is never "is it blocked" but "I approved this, why is it still blocked".

With no .contentrain/approval-policies.json, DEFAULT_APPROVAL_POLICY applies: read-only work proceeds, everything else wants one reviewer on the diff. Whether a project consults the evaluator at all is still governed by its workflow setting.

plan_hash ​

ts
import { computePlanHash } from '@contentrain/types'

const plan_hash = await computePlanHash(plan)

SHA-256 over canonical JSON (sorted keys, 2-space indent, trailing newline) of the plan's semantic fields. Excluded: plan_hash itself, id, created_at, created_by, idempotency_key — who built a plan, when, under which run id and with which deduplication key do not change what the plan will do, and regenerating the same operation must produce the same hash or idempotency and approval binding both break.

Everything else is covered, so a widened scope, an added step, a raised estimate or a withdrawn rollback each invalidate every approval the plan had collected.

Async because it uses Web Crypto, which works in Node 18+, Deno, Bun, workers and browsers alike — this package is consumed in all of them and must not reach for node:crypto. A non-cryptographic hash was rejected: approvals are pinned to this value, so a collision is an approval bypass.

SourceDeltaPlan ​

The WordPress→repository delta — not contentrain_reconcile, which merges two git branches through their common ancestor and knows nothing about WordPress. A source delta must be written to the repository before reconcile runs on the git side.

It carries explicit deletion tombstones, because modified_after is a filter on changed records and never reports a deletion: a post deleted in WordPress simply stops appearing. deletions_detectable: false must not be read as "nothing was deleted" — it means this cursor could not tell. It also carries slug moves (which generate redirects) and semantic conflicts, where the same record changed at the origin and in the repository.

A moved entry carries path_before/path_after next to the slugs: a page that changed parent, a renamed term base or a dated permalink moves without a slug change, and redirects are generated from the path. A deleted entry may say deleted_kind: 'trashed' (still at the origin, recoverable) or 'purged'. A plan that could see deletions in general can still name the origin types it could not — deletions_undetectable_types, for a type that left the scope.

SourceInventory ​

Every origin record in scope at one moment, and what a bridge_inventory cursor's inventory_hash identifies. Deletions and moves are proven by comparing two inventories; a modified_after query only narrows which bodies to fetch again. Each SourceInventoryRecord is keyed by wp_type + wp_id (post 5 and category 5 are different records) and carries a fingerprint of the mapped record — updated is decided on it, never on modified_at, because a meta-only edit in WordPress leaves the modified date alone. old_slugs (WordPress _wp_old_slug) corroborates a move but is never the authority. A type in the earlier inventory's scope but missing from the later one left the scope; its records were not deleted. A password-protected record is inventoried with protected: true (it has no public address), so removing the password is an updated, not a created.

inventory_hash is reproducible from the records alone: lowercase hex SHA-256 of the records sorted by wp_type (byte order) then wp_id (numeric), each serialized as the JSON array [wp_type, wp_id, fingerprint, path ?? null, status ?? null] exactly as JSON.stringify prints it (no spaces, slashes and non-ASCII unescaped), joined by \n with no trailing newline.

Reserved paths ​

RESERVED_PATHS names four files under .contentrain/ that this repository has claimed but does not yet write:

PathWill hold
.contentrain/capabilities.jsonCapabilityManifest
.contentrain/automations.jsonAutomationDefinition[]
.contentrain/approval-policies.jsonApprovalPolicyFile
.contentrain/redirects.jsonSource→destination URL map

.contentrain/ is a shared namespace — Studio, the migration engine and a customer's own tooling all write into it — so a name claimed here cannot later be taken for something else, and the tools that walk the directory know these four are expected rather than stray.

Until a tool owns one, the behaviour is narrow and pinned by tests in @contentrain/mcp:

  • contentrain_doctor and contentrain_validate ignore them completely. They are not orphans, not broken content, and not the validator's business.
  • contentrain_reconcile treats each as one opaque file: it takes the side that changed it, and reports file_conflict when both sides did. It never merges their interiors — a field-level union on an approval policy would produce a policy nobody wrote.

SEO, routing, redirects, interface text and integrations in RawIR ​

From the bridge rung, RawIR can carry five optional parts:

  • seo (RawSeo): the plugin that serves the head (serving), each provider's status and settings, and each page's title, description, canonical, robots, Open Graph, Twitter and JSON-LD values, keyed by post:<id> or term:<taxonomy>:<id>. status: 'none' means the site has no SEO plugin; it is not a missing export. resolved: true marks values the running plugin rendered, and robots_served is what the page actually carries.
  • routing (RawRouting): the permalink structure, bases, front and posts pages, and each post type's and taxonomy's permastruct.
  • redirects_excluded (RawRedirectExcluded): rules a source holds that the site does not serve as a plain redirect, each with its reason. Served plus excluded accounts for the source's whole table.
  • hardcoded_text (RawHardcodedText): interface text outside the content tables (theme templates, scripts, widgets, menus, options, Customizer mods, rendered pages). Each candidate is transferred to a named target, excluded with a reason, or — when its source could not be read — reported in errors. Candidates merge only when text, locale and context are equal; keys depend on text and context, never on file or line. Page text also found in source is excluded as rendered-from-source and points back with related.
  • integrations (RawIntegration[]): outside services the site is connected to, each with a category, evidence (never a value), reconnect_required and secret_present. Only whether a credential is set is exported, never the credential. The integration_reconnect_required issue (IntegrationReconnectRequiredIssue) lists every service to connect again.

RawRedirect gains id, match (url / regex / start / contains / end) and regex. Only url without regex is a one-to-one mapping. Writing a pattern rule as a literal from produces the wrong redirect.

Migration contracts

The sibling family (RawIR, ProjectIR, CapabilityManifest, MigrationHandoff) is documented in the package README.

Comments export by repository path ​

MigrationHandoff.comments.export (HandoffCommentsExport) says where the full comments export (contentrain-comments@1) is. A producer writes at most one of:

FieldWhen
pathLarge exports: a file in the generated repository. The consumer reads it at the same commit SHA it read the handoff from (not a branch name, which can move between the two reads), with the repository access it already has, so a private repository needs no public URL
urlA file fetched over http(s)
inlineSmall exports, embedded in the handoff

With none of them, the export exists but has no reference yet (a large export with no repository to put it in): a valid state with nothing to import.

The file at path or url is the export as UTF-8 JSON. bytes (size) and sha256 (lowercase hex) are taken over its raw bytes as stored, so a consumer can refuse an oversized export before reading it and check what it read.

path is a POSIX path relative to the repository root, in one normal form: no leading / or ./, no ., .., .git or empty segment, no backslash, no scheme or drive, no control character, Unicode in NFC. isRepoRelativePath() is that check, so a path can never leave the repository or reach its git internals, and no two producers spell one file two ways. A consumer reading a local checkout also checks that the file is not a symlink (lstat) before reading it.

ts
import { commentsExportSource, validateHandoffCommentsExport } from '@contentrain/types'

const source = commentsExportSource(handoff.comments?.export)
// { kind: 'path', path } | { kind: 'url', url } | { kind: 'inline', export } | undefined

commentsExportSource() reads path first, then url, then inline, and skips a path that is not in normal form and a url that is not http(s). validateHandoffCommentsExport() returns { errors, warnings } for a producer's own check or a consumer's report: errors for several sources or a bad path, url, size or hash; warnings when a file at path or url comes without sha256 or bytes.

Migrate → Studio claim ​

A paid Migrate order includes a Studio trial. At delivery, Migrate hands the customer to Studio with a signed claim token: a compact JWS, alg: "EdDSA" (Ed25519), signed with Migrate's private key and verified with its public key (no shared secret; optional kid for rotation). MigrateStudioClaim is the payload both apps bind to.

ClaimMeaning
iss / audcontentrain-migrate / contentrain-studio
subMigrate account id
jti, iat, expsingle-use id; exp − iat ≤ 1800 s
vcontract version (1)
order_idMigrate order; Studio grants once per order
emailverified at Migrate; shown, not required to match the GitHub login
plan, plan_evidencestarter | pro, and the measurements it was sized on (limit_key, measured, limit, optional capability)
trial_days1..90 (v1: 60), never taken from the client
repo{ provider: 'github', owner, name } of the delivered site
capabilitiesoptional discovery summary for the claim screen
originoptional migrated site as a bare origin (https://host; http: only for localhost). Studio stores it on the grant and fetches migrated media from it alone
comments_exportoptional { url, token, expires_at, comments }: Migrate's fixed address for the order's comment export (PII removed), fetched with token (a per-job signed JWS) as Authorization: Bearer, valid until expires_at (end of the grant window; missing, invalid or expired → 404). The address carries no secret. The export never enters the repository. Studio fetches it server-side only, from hosts on its own Migrate allowlist
ts
import { validateMigrateStudioClaim } from '@contentrain/types'

// after verifying the JWS signature:
const result = validateMigrateStudioClaim(payload, { now: Math.floor(Date.now() / 1000) })
if (!result.ok) throw new Error(result.errors.join('; '))

validateMigrateStudioClaim checks shape, ranges and — with now — the validity window (60 s skew). It does no cryptography: the signature, jti replay and order_id uniqueness are the consumer's job. isMigrateStudioClaim is the shape-only type guard. A malformed comments_export does not fail the claim: it is dropped from result.claim and reported in result.warnings (comments_export.*: …), and the trial opens without the export. The guard is stricter: it returns false for such a claim, since it narrows the input itself, malformed export included. Read result.claim instead.

Import Style ​

Type-only imports (recommended for application code):

ts
import type { ModelDefinition, ContentrainConfig, FieldDef } from '@contentrain/types'

Runtime imports (when you need constants):

ts
import { PATH_PATTERNS, CANONICAL_JSON, CONTENTRAIN_DIR } from '@contentrain/types'

Stability ​

This package is the shared public contract across the ecosystem:

  • Types exported from the package root are the public surface
  • Packages depend on these shared definitions instead of redefining domain types
  • Breaking changes here are ecosystem-level breaking changes
  • The package should stay small, dependency-light, and stable

Development ​

From the monorepo root:

bash
pnpm --filter @contentrain/types build
pnpm --filter @contentrain/types test
pnpm --filter @contentrain/types typecheck
  • MCP Tools — Validates and writes models using these types
  • CLI — Reads config and context using these types
  • Query SDK — Codegen consumes model definitions and field types
  • Rules — Aligns with the same vocabulary
  • Skills — Workflow procedures built on these contracts
  • Model Kinds — Detailed specification of the four model kinds
  • Field Types — Comprehensive field type reference
  • Configuration — Config file schemas and directory layout