Types
@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
ContentrainConfigandContextJson - SDK codegen consumes
ModelDefinitionandFieldDef - 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
pnpm add @contentrain/typesFor type-only usage (no runtime exports needed):
pnpm add -D @contentrain/typesRequirements:
- Node.js 22+
- TypeScript 5.0+
Quick Example
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
| Type | Values | Reference |
|---|---|---|
FieldType | 27 field types (string, number, boolean, relation, ...) | Field Types |
ModelKind | singleton, collection, document, dictionary | Model Kinds |
ContentStatus | draft, in_review, published, rejected, archived | |
ContentSource | agent, human, import | |
WorkflowMode | auto-merge, review | Configuration |
StackType | nuxt, next, astro, sveltekit, remix, + 25 more | Configuration |
Platform | web, mobile, api, desktop, static, other | |
ContextSource | mcp-local, mcp-studio, studio-ui | |
CollectionRuntimeFormat | map, array | |
LocaleStrategy | file, suffix, directory, none | |
FileFramework | vue, svelte, jsx, astro, script |
Core Interfaces
| Interface | Purpose |
|---|---|
FieldDef | Field schema definition (type, required, unique, constraints) |
ModelDefinition | Full model schema (id, kind, domain, fields, i18n, locale coverage, locale strategy) |
ModelLocaleScope | The locales one model is checked against, plus whether that list came from the model (locales) or the project |
ContentrainConfig | Project configuration (stack, workflow, locales, domains) |
Vocabulary | Shared terms for content consistency |
EntryMeta | Per-entry metadata (status, source, timestamps) |
AssetEntry | Asset registry entry (path, type, size, alt) |
ValidationError | Structured validation issue — severity is error, warning, or notice (notices flag drift like drafts beside published entries) |
ValidationResult | Validation outcome (valid flag + error list) |
ContextJson | Last operation context written by MCP |
ModelSummary | Lightweight model info for listing operations |
Provider Contract Types
Third-party developers can implement custom providers by implementing these interfaces:
| Interface / Type | Purpose |
|---|---|
RepoProvider | Full provider contract: read, write, branch, merge, diff operations, plus optional media?: MediaProvider, getMergeBase? and createMergeCommit? (reconcile) members |
RepoReader | Read-only interface (readFile, listDirectory, fileExists) |
RepoWriter | Write interface (applyPlan for atomic commits) |
ProviderCapabilities | Capability flags (localWorktree, sourceRead, sourceWrite, pushRemote, branchProtection, pullRequestFallback, astScan, optional mergeCommit) |
FileChange | A single file addition, modification, or deletion ({ path, content: string | null }) |
ApplyPlanInput | Input for a single atomic commit (branch, changes, message, author, optional base) |
Commit | Result of a commit operation (sha, message, author, timestamp) |
Branch | Git branch metadata (name, sha, protected) |
FileDiff | File change within a plan (path, status, before, after) |
MergeResult | Merge outcome (merged flag, sha, pullRequestUrl, optional sync?: SyncResult for LocalProvider, optional remote? source-branch cleanup outcome) |
SyncResult | Selective 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 |
ConflictItem | One 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 |
ConflictCode | Closed union of conflict kinds — adding a value is a minor + changelog entry; renaming or removing one is breaking |
ConflictResolution | A 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 |
CommitAuthor | Commit author metadata (name, email) |
Media facet types (implemented by providers exposing a media stack — drives the contentrain_media_* tools):
| Interface / Type | Purpose |
|---|---|
MediaProvider | Optional RepoProvider.media facet: list / get / ingest / update / delete |
MediaAsset | One asset — id, path (media/...), optional url, mime, size, alt, tags, createdAt, meta |
MediaListOptions | List filters (search, tag, limit, cursor) |
MediaListResult | List page (assets, optional nextCursor, total) |
MediaIngestInput | URL-based ingest input (url, optional filename, alt, tags) |
MediaUpdateInput | Metadata patch (alt, tags, filename) |
Pre-built capability set:
LOCAL_CAPABILITIES— Capability set for LocalProvider:localWorktree,sourceRead,sourceWrite,pushRemote,astScanandmergeCommitenabled;branchProtectionandpullRequestFallbackarefalse(a local worktree has no remote protection or PR flow). Exported from@contentrain/typesfor 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:
| Type | Model Kind | Shape |
|---|---|---|
SingletonContentFile | Singleton | Record<string, unknown> |
CollectionContentFile | Collection | Record<string, Record<string, unknown>> (object-map by entry ID) |
DictionaryContentFile | Dictionary | Record<string, string> (flat key-value, all strings) |
Output Types
How MCP and SDK return content to consumers (different from storage format):
| Type | Description |
|---|---|
CollectionEntry | { id: string } & Record<string, unknown> |
CollectionContentOutput | CollectionEntry[] (array format) |
DocumentEntry | { slug, frontmatter, body } — parsed markdown |
DocumentContentOutput | DocumentEntry[] |
PolymorphicRelationRef | { model, ref } — cross-model relation storage |
Metadata Types
| Type | Description |
|---|---|
SingletonMeta | Alias for EntryMeta |
CollectionMeta | Record<string, EntryMeta> — per-entry metadata map |
DocumentMeta | Alias for EntryMeta |
DictionaryMeta | Alias for EntryMeta |
Scan & Graph Types
Used by the normalize flow (scan, extract, reuse):
| Type | Purpose |
|---|---|
ScanCandidate | Hardcoded string candidate with file, line, column, context |
DuplicateGroup | Group of repeated strings with occurrence locations |
GraphNode | File node in the project graph (category, imports, strings) |
ProjectGraph | Full project structure graph (pages, components, layouts) |
ScanCandidatesResult | Scan output with candidates, duplicates, and stats |
ScanSummaryResult | High-level scan summary (directory breakdown, top repeated) |
StringContext | Where a string appears (jsx_text, template_attribute, ...) |
FileCategory | File classification (page, component, layout, other) |
NormalizePlan | Normalize plan exchanged between scan and apply |
NormalizePlanModel | Model proposal inside a normalize plan |
NormalizePlanExtraction | One extraction target (content entry to create) |
NormalizePlanPatch | One 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:
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'| Constant | Value | Purpose |
|---|---|---|
CONTENTRAIN_DIR | '.contentrain' | Root directory name |
CONTENTRAIN_BRANCH | 'contentrain' | Dedicated content branch name |
PATH_PATTERNS | Object | Canonical paths for config, models, content, meta |
SLUG_PATTERN | RegExp | Validates slug format |
ENTRY_ID_PATTERN | RegExp | Validates entry IDs |
LOCALE_PATTERN | RegExp | Validates ISO locale codes |
CANONICAL_JSON | Object | Deterministic serialization rules |
RESERVED_PATHS | readonly string[] | Four .contentrain/ files this repository claims but does not yet write — see Reserved paths |
SECRET_PATTERNS | ReadonlyArray<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):
| Function | Purpose |
|---|---|
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):
| Function | Purpose |
|---|---|
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):
| Function | Purpose |
|---|---|
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
| Type | Purpose |
|---|---|
SyncResult | Result of selective file sync (synced files, skipped files, warning) |
ContentrainError | Structured error with code, message, agent hint, and developer action |
ScaffoldTemplate | Template 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
| Value | Came back as |
|---|---|
He said "Hi" | He said \"Hi\" — quotes stripped without decoding the escapes |
C:\path\to | C:\\path\\to, doubling again on every further save |
line one⏎line two | line 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 barekey: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
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.
| Type | Purpose |
|---|---|
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 |
ApprovalRequirement | An outstanding demand, carrying because — the risk class of the rule that produced it — so a UI can say why a gate appeared |
ApprovalGrant | A decision actually given, bound to an exact plan_hash. Change the plan and its grants stop applying |
ActorRef | Who is acting. kind (human / agent / system) is load-bearing: an agent may never approve its own work |
Plans and receipts
| Type | Purpose |
|---|---|
ExecutionPlan | An operation fully described before it runs: steps, union scope, risk, estimate, rollback, assumed repository state |
ExecutionStep | One tool invocation, with its own risk and scope |
ExecutionScope | What is touched — models, locales, entries, routes, files, assets, providers, external domains. An absent field means "none", not "unknown" |
ExecutionEstimate / ExecutionCost | Cost 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 |
ExecutionReceipt | What happened: status, approvals, checkpoints, verification, measured cost, and the scope actually touched — the same ExecutionScope shape, so prediction and outcome can be subtracted |
RollbackPlan | The undo as a command, not a promise. available: false tells the approver before deciding |
RunStatus | draft → planned → awaiting_approval → approved → scheduled → queued → running → verifying → completed, plus the interrupted states |
DeploymentTarget | Where a build is published. Carries a secret_ref, never a secret — this document is written to git |
AutomationDefinition | Reserved shape for .contentrain/automations.json; nothing reads it yet |
The evaluator
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 personIt 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.
| Rule | Why |
|---|---|
| A plan cannot understate itself | effectiveRisk() 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 met | Merging 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 ladder | Every 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 approves | Not 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 diff | Presented with a different branch tip than the one reviewed, it does not count |
now is an input | Nothing 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
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:
| Path | Will hold |
|---|---|
.contentrain/capabilities.json | CapabilityManifest |
.contentrain/automations.json | AutomationDefinition[] |
.contentrain/approval-policies.json | ApprovalPolicyFile |
.contentrain/redirects.json | Source→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_doctorandcontentrain_validateignore them completely. They are not orphans, not broken content, and not the validator's business.contentrain_reconciletreats each as one opaque file: it takes the side that changed it, and reportsfile_conflictwhen 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 bypost:<id>orterm:<taxonomy>:<id>.status: 'none'means the site has no SEO plugin; it is not a missing export.resolved: truemarks values the running plugin rendered, androbots_servedis 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 inerrors. 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 asrendered-from-sourceand points back withrelated.integrations(RawIntegration[]): outside services the site is connected to, each with a category, evidence (never a value),reconnect_requiredandsecret_present. Only whether a credential is set is exported, never the credential. Theintegration_reconnect_requiredissue (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:
| Field | When |
|---|---|
path | Large 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 |
url | A file fetched over http(s) |
inline | Small 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.
import { commentsExportSource, validateHandoffCommentsExport } from '@contentrain/types'
const source = commentsExportSource(handoff.comments?.export)
// { kind: 'path', path } | { kind: 'url', url } | { kind: 'inline', export } | undefinedcommentsExportSource() 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.
| Claim | Meaning |
|---|---|
iss / aud | contentrain-migrate / contentrain-studio |
sub | Migrate account id |
jti, iat, exp | single-use id; exp − iat ≤ 1800 s |
v | contract version (1) |
order_id | Migrate order; Studio grants once per order |
email | verified at Migrate; shown, not required to match the GitHub login |
plan, plan_evidence | starter | pro, and the measurements it was sized on (limit_key, measured, limit, optional capability) |
trial_days | 1..90 (v1: 60), never taken from the client |
repo | { provider: 'github', owner, name } of the delivered site |
capabilities | optional discovery summary for the claim screen |
origin | optional 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_export | optional { 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 |
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):
import type { ModelDefinition, ContentrainConfig, FieldDef } from '@contentrain/types'Runtime imports (when you need constants):
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:
pnpm --filter @contentrain/types build
pnpm --filter @contentrain/types test
pnpm --filter @contentrain/types typecheckRelated Pages
- 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