Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 83 additions & 32 deletions server/utils/cdn-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,12 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
}
}

const locales = config.locales?.supported ?? [config.locales?.default ?? 'en']
// Non-i18n content/meta lives under the DEFAULT locale (that's where MCP
// writes it), which is NOT necessarily supported[0]. Keep this distinct
// from `locales[0]` — mirrors brain-cache.ts's `locale === 'data' ?
// defaultLocale : locale` contract.
const defaultLocale = config.locales?.default ?? 'en'
const locales = config.locales?.supported ?? [defaultLocale]

// 2. Load all model definitions
progress({ phase: 'init', message: 'Loading model definitions...' })
Expand Down Expand Up @@ -288,33 +293,77 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul

try {
if (model.kind === 'document') {
const indexEntries = await buildDocumentModel(projectId, git, cdn, ctx, model, locale, branch, uploadedPaths)
// Non-i18n document meta lives under the default locale (MCP's
// contract), so decouple the meta locale from the content locale.
const metaLocale = model.i18n ? locale : defaultLocale
const indexEntries = await buildDocumentModel(projectId, git, cdn, ctx, model, locale, metaLocale, branch, uploadedPaths)
const cdnLocale = model.i18n ? locale : 'data'
addBundleEntry(model.i18n ? locale : null, `documents/${model.id}/_index/${cdnLocale}.json`, indexEntries)
}
else {
// JSON kinds: collection, singleton, dictionary
const contentPath = resolveContentPath(ctx, model, effectiveLocale === 'data' ? 'data' : locale)
const raw = await git.readFile(contentPath, branch)
let raw: string
try {
raw = await git.readFile(contentPath, branch)
}
catch {
// Content file genuinely absent for this locale — expected, there
// is nothing to publish. This is the ONLY silently-skipped case;
// any error past this point is real and reaches the outer catch.
continue
}
let content = JSON.parse(raw)

// Filter by meta (published only)
if (model.kind === 'collection') {
const metaPath = resolveMetaPath(ctx, model, effectiveLocale === 'data' ? locales[0]! : locale)
let meta: Record<string, EntryMeta> = {}
try {
meta = JSON.parse(await git.readFile(metaPath, branch)) as Record<string, EntryMeta>
}
catch { /* no meta */ }
// Read this model's meta ONCE. A missing meta *file* means "no status
// info" → legacy include-all (shouldIncludeEntry(undefined) === true).
// A meta file that fails to PARSE is corruption — let it throw to the
// outer catch instead of silently publishing everything unfiltered.
const metaPath = resolveMetaPath(ctx, model, effectiveLocale === 'data' ? defaultLocale : locale)
let metaRaw: string | null = null
try {
metaRaw = await git.readFile(metaPath, branch)
}
catch {
// No meta file for this model/locale — legacy content without meta.
}
const metaParsed = metaRaw === null
? null
: JSON.parse(metaRaw) as Record<string, EntryMeta>

// Filter entries by publication status
// Filter by publication status. `outputMeta` is what ships alongside
// the content (null → the model has no meta file, nothing to upload).
let outputMeta: Record<string, EntryMeta> | EntryMeta | null = null

if (model.kind === 'collection') {
// Collection meta is an id-keyed map — filter per entry.
const metaMap = metaParsed ?? {}
const filtered: Record<string, unknown> = {}
for (const [id, entry] of Object.entries(content as Record<string, unknown>)) {
if (shouldIncludeEntry(meta[id])) {
filtered[id] = entry
}
if (shouldIncludeEntry(metaMap[id])) filtered[id] = entry
}
content = filtered

if (metaParsed !== null) {
const filteredMeta: Record<string, EntryMeta> = {}
for (const [id, m] of Object.entries(metaMap)) {
if (shouldIncludeEntry(m)) filteredMeta[id] = m
}
outputMeta = filteredMeta
}
}
else {
// singleton / dictionary: MCP writes meta as a SINGLE object
// ({ status, source, updated_by }), not an id-map. One status gates
// the whole artifact — a draft/scheduled/expired unit must not be
// served at all.
const single = metaParsed as EntryMeta | null
if (!shouldIncludeEntry(single ?? undefined)) {
// Not published — skip content AND meta so the stale-object sweep
// garbage-collects any previously-published copy.
continue
}
outputMeta = single
}

// Safety net: rewrite any relative media paths that reached the
Expand All @@ -329,30 +378,23 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
totalSizeBytes += Buffer.byteLength(data)
addBundleEntry(model.i18n ? locale : null, outputPath, content)

// Upload meta (filtered)
try {
const metaPath = resolveMetaPath(ctx, model, effectiveLocale === 'data' ? locales[0]! : locale)
const metaRaw = await git.readFile(metaPath, branch)
const metaData = JSON.parse(metaRaw) as Record<string, EntryMeta>

// Only include published entries' meta
const filteredMeta: Record<string, EntryMeta> = {}
for (const [id, m] of Object.entries(metaData)) {
if (shouldIncludeEntry(m)) filteredMeta[id] = m
}

// Upload meta alongside the content (skip when the model has no meta).
if (outputMeta !== null) {
const metaOutput = `meta/${model.id}/${effectiveLocale === 'data' ? 'data' : locale}.json`
const metaStr = JSON.stringify(filteredMeta, null, 2)
const metaStr = JSON.stringify(outputMeta, null, 2)
await cdn.putObject(projectId, metaOutput, metaStr, 'application/json')
uploadedPaths.add(metaOutput)
filesUploaded++
totalSizeBytes += Buffer.byteLength(metaStr)
}
catch { /* no meta to upload */ }
}
}
catch {
// Content file doesn't exist for this locale — skip
catch (e) {
// A content/meta parse error, media-normalize failure, or upload error
// for THIS model+locale. Previously swallowed → published content
// silently vanished from the CDN. Surface it (best-effort per model,
// the build continues for the rest) instead of dropping it on the floor.
reportDataLossRisk(e, { op: 'cdn-build.model', projectId, modelId: model.id, locale })
}
}
}
Expand Down Expand Up @@ -528,6 +570,14 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
* Directory structure:
* i18n=true: {contentDir}/{slug}/{locale}.md
* i18n=false: {contentDir}/{slug}.md (flat files, not directories)
*
* NOTE: the slug enumeration below assumes the default `file` (and the
* equivalent `none`) locale_strategy layout. `suffix`
* ({contentDir}/{slug}.{locale}.md) and `directory`
* ({contentDir}/{locale}/{slug}.md) documents would enumerate wrong here —
* only the per-file READ path (resolveContentPath) honors those strategies
* today. Generalizing document listing is a separate follow-up (the same
* limitation exists in brain-cache's document walk).
*/
async function buildDocumentModel(
projectId: string,
Expand All @@ -536,6 +586,7 @@ async function buildDocumentModel(
ctx: { contentRoot: string },
model: ModelDefinition,
locale: string,
metaLocale: string,
branch: string,
uploadedPaths: Set<string>,
): Promise<Array<Record<string, unknown>>> {
Expand Down Expand Up @@ -576,7 +627,7 @@ async function buildDocumentModel(

// Check meta
try {
const metaPath = resolveMetaPath(ctx, model, locale, slug)
const metaPath = resolveMetaPath(ctx, model, metaLocale, slug)
const metaRaw = JSON.parse(await git.readFile(metaPath, branch)) as EntryMeta
if (!shouldIncludeEntry(metaRaw)) continue
}
Expand Down
91 changes: 48 additions & 43 deletions server/utils/content-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ export function resolveModelPath(ctx: PathContext, modelId: string): string {
return prefixed(ctx.contentRoot, PATH_PATTERNS.model.replace('{modelId}', modelId))
}

export function resolveContentPath(
/**
* Content directory (content-root-relative) for a model. Mirrors MCP's
* `contentDir` (`@contentrain/mcp/core/ops/paths`): a `content_path` override
* moves files OUTSIDE `.contentrain/`, otherwise they live under the model id.
* The content_path validation is Studio-only hardening MCP's helper omits.
*/
function resolveContentDirForModel(
ctx: PathContext,
model: Pick<ModelDefinition, 'id' | 'kind' | 'domain' | 'i18n' | 'content_path'>,
locale: string,
slug?: string,
model: Pick<ModelDefinition, 'id' | 'domain' | 'content_path'>,
): string {
// Custom content_path override — files live OUTSIDE .contentrain/
if (model.content_path) {
// Validate content_path — prevent path traversal and sensitive path access
const normalized = model.content_path.replace(/\\/g, '/')
Expand All @@ -42,50 +45,52 @@ export function resolveContentPath(
if (sensitivePatterns.some(p => lowerNorm === p || lowerNorm.startsWith(`${p}/`))) {
throw new Error(`Invalid content_path: "${model.content_path}" — targets a protected directory`)
}
const basePath = prefixed(ctx.contentRoot, model.content_path)
if (model.kind === 'document') {
if (model.i18n && slug) return `${basePath}/${slug}/${locale}.md`
if (slug) return `${basePath}/${slug}.md`
return basePath
}
// JSON kinds with content_path override
if (!model.i18n) return `${basePath}/data.json`
return `${basePath}/${locale}.json`
return prefixed(ctx.contentRoot, model.content_path)
}
return prefixed(ctx.contentRoot, `${CONTENTRAIN_DIR}/content/${model.domain}/${model.id}`)
}

// i18n: false → uses noLocale pattern (data.json)
if (!model.i18n && model.kind !== 'document') {
const pattern = PATH_PATTERNS.content.noLocale as string
const resolved = pattern
.replace('{domain}', model.domain)
.replace('{modelId}', model.id)
return prefixed(ctx.contentRoot, resolved)
}
/**
* Resolve the on-disk path for a content file.
*
* CRITICAL: honors `model.locale_strategy` — MUST stay byte-for-byte aligned
* with MCP's canonical `contentFilePath`/`documentFilePath`
* (`@contentrain/mcp/core/ops/paths`), which is what the write path (`planContentSave`)
* actually commits. Resolving with the wrong strategy reads a non-existent path
* → silent skip (missing content in the CDN build + brain cache). `i18n: false`
* always collapses to `data.json` / `{slug}.md` regardless of strategy.
*
* With no `slug` for a document kind, returns the model's content directory
* (callers use it for `listDirectory`).
*/
export function resolveContentPath(
ctx: PathContext,
model: Pick<ModelDefinition, 'id' | 'kind' | 'domain' | 'i18n' | 'content_path' | 'locale_strategy'>,
locale: string,
slug?: string,
): string {
const dir = resolveContentDirForModel(ctx, model)
const strategy = model.locale_strategy ?? 'file'

// Standard documents live UNDER the model id —
// `.contentrain/content/{domain}/{modelId}/{slug}/{locale}.md` (i18n) or
// `.../{slug}.md` — matching how `@contentrain/mcp` planContentSave writes
// them. `PATH_PATTERNS.content.document` omits `{modelId}`, so resolving via
// the generic pattern below produced a path that doesn't exist on disk (a
// 404 on read). Build the model-id path directly.
if (model.kind === 'document') {
const base = `${CONTENTRAIN_DIR}/content/${model.domain}/${model.id}`
if (model.i18n && slug) return prefixed(ctx.contentRoot, `${base}/${slug}/${locale}.md`)
if (slug) return prefixed(ctx.contentRoot, `${base}/${slug}.md`)
return prefixed(ctx.contentRoot, base)
if (!slug) return dir
if (!model.i18n) return `${dir}/${slug}.md`
switch (strategy) {
case 'suffix': return `${dir}/${slug}.${locale}.md`
case 'directory': return `${dir}/${locale}/${slug}.md`
case 'none': return `${dir}/${slug}.md`
default: return `${dir}/${slug}/${locale}.md`
}
}

// Standard path from PATH_PATTERNS
const pattern = PATH_PATTERNS.content[model.kind as keyof typeof PATH_PATTERNS.content]
?? PATH_PATTERNS.content.collection

const resolved = (pattern as string)
.replace('{domain}', model.domain)
.replace('{modelId}', model.id)
.replace('{locale}', locale)
.replace('{slug}', slug ?? '')

return prefixed(ctx.contentRoot, resolved)
// JSON kinds: collection, singleton, dictionary
if (!model.i18n) return `${dir}/data.json`
switch (strategy) {
case 'suffix': return `${dir}/${model.id}.${locale}.json`
case 'directory': return `${dir}/${locale}/${model.id}.json`
case 'none': return `${dir}/${model.id}.json`
default: return `${dir}/${locale}.json`
}
}

export function resolveMetaPath(
Expand Down
Loading
Loading