@growth-labs/cms 0.8.33 → 0.8.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/engine/content-metadata.d.ts +1 -0
  2. package/dist/engine/content-metadata.d.ts.map +1 -1
  3. package/dist/engine/content-metadata.js +32 -0
  4. package/dist/engine/content-metadata.js.map +1 -1
  5. package/dist/engine/publisher.d.ts +4 -0
  6. package/dist/engine/publisher.d.ts.map +1 -1
  7. package/dist/engine/publisher.js +25 -9
  8. package/dist/engine/publisher.js.map +1 -1
  9. package/dist/foundry-emit.d.ts +61 -1
  10. package/dist/foundry-emit.d.ts.map +1 -1
  11. package/dist/foundry-emit.js +16 -0
  12. package/dist/foundry-emit.js.map +1 -1
  13. package/dist/routes/content-insights.d.ts.map +1 -1
  14. package/dist/routes/content-insights.js +78 -19
  15. package/dist/routes/content-insights.js.map +1 -1
  16. package/dist/routes/content.d.ts +2 -0
  17. package/dist/routes/content.d.ts.map +1 -1
  18. package/dist/routes/content.js +4 -0
  19. package/dist/routes/content.js.map +1 -1
  20. package/dist/routes/foundry-publish.d.ts.map +1 -1
  21. package/dist/routes/foundry-publish.js +196 -10
  22. package/dist/routes/foundry-publish.js.map +1 -1
  23. package/dist/routes/fronts-publish.d.ts +1 -1
  24. package/dist/routes/fronts-publish.d.ts.map +1 -1
  25. package/dist/routes/fronts-publish.js.map +1 -1
  26. package/dist/routes/media-lib.d.ts +2 -0
  27. package/dist/routes/media-lib.d.ts.map +1 -1
  28. package/dist/routes/media-lib.js +17 -0
  29. package/dist/routes/media-lib.js.map +1 -1
  30. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  31. package/dist/ui/editor/ContentForm.js +7 -9
  32. package/dist/ui/editor/ContentForm.js.map +1 -1
  33. package/dist/ui/editor/content-payload.d.ts +5 -0
  34. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  35. package/dist/ui/editor/content-payload.js +13 -0
  36. package/dist/ui/editor/content-payload.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/engine/content-metadata.ts +35 -0
  39. package/src/engine/publisher.ts +33 -7
  40. package/src/foundry-emit.ts +22 -0
  41. package/src/routes/content-insights.ts +85 -18
  42. package/src/routes/content.ts +6 -0
  43. package/src/routes/foundry-publish.ts +215 -9
  44. package/src/routes/fronts-publish.ts +4 -1
  45. package/src/routes/media-lib.ts +18 -0
  46. package/src/ui/editor/ContentForm.tsx +8 -13
  47. package/src/ui/editor/content-payload.ts +17 -0
@@ -35,6 +35,15 @@ const HERO_IMAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9._\-/]*$/
35
35
  export const FoundryArticleV2Schema = z
36
36
  .object({
37
37
  schemaVersion: z.literal(FOUNDRY_ARTICLE_SCHEMA_VERSION),
38
+ createOnly: z.literal(true).optional(),
39
+ /** Actual CMS source revision pinned by a strategy-led improvement. */
40
+ expectedRevisionId: z.string().uuid().optional(),
41
+ /** Continue this writer's exact private snapshot, without adopting newer edits. */
42
+ expectedRequestSHA256: z
43
+ .string()
44
+ .regex(/^[a-f0-9]{64}$/)
45
+ .optional(),
46
+ expectedCanonicalVersion: z.number().int().positive().optional(),
38
47
  slug: z.string().refine(isCanonicalSlug, {
39
48
  message: 'slug must already be canonical: lowercase a-z, 0-9 and single hyphens, 3-512 chars',
40
49
  }),
@@ -70,6 +79,19 @@ export const FoundryArticleV2Schema = z
70
79
  .optional(),
71
80
  })
72
81
  .strict()
82
+ .refine(
83
+ (body) =>
84
+ (body.expectedRequestSHA256 === undefined) ===
85
+ (body.expectedCanonicalVersion === undefined) &&
86
+ !(
87
+ body.expectedRequestSHA256 &&
88
+ (body.createOnly || body.expectedRevisionId || body.adoptSourceId)
89
+ ),
90
+ {
91
+ message: 'own working update requires both pins and cannot create or adopt',
92
+ path: ['expectedRequestSHA256'],
93
+ },
94
+ )
73
95
  .refine((body) => !(body.hero_image_key && body.hero_image_url), {
74
96
  message: 'send hero_image_key or hero_image_url, not both',
75
97
  path: ['hero_image_key'],
@@ -11,7 +11,11 @@ import {
11
11
  undismissInsight,
12
12
  } from '../engine/content-insights.js'
13
13
  import { verifyHmac } from '../engine/foundry-dispatch.js'
14
- import { listPublishedContent, PublishedContentReadError } from '../engine/published-content.js'
14
+ import {
15
+ getPublishedContentBySlug,
16
+ listPublishedContent,
17
+ PublishedContentReadError,
18
+ } from '../engine/published-content.js'
15
19
  import { InsightsIngestPayloadSchema } from '../schema/insights-ingest.js'
16
20
  import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
17
21
  import type { CmsRouteConfig } from './config.js'
@@ -21,6 +25,7 @@ import {
21
25
  resolveFoundryHmacSecret as resolveFoundryHmacSecretForInputs,
22
26
  verifyFoundrySignedRequest,
23
27
  } from './fronts-publish.js'
28
+ import { readReadyImagePublicUrl } from './media-lib.js'
24
29
 
25
30
  const DismissSchema = z
26
31
  .object({
@@ -56,6 +61,22 @@ const PublishedArticleStateSchema = z
56
61
  })
57
62
  .strict()
58
63
 
64
+ const PublishedArticleSourceSchema = z
65
+ .object({
66
+ ok: z.literal(true),
67
+ schema: z.literal('cms.published-article-source.v1'),
68
+ site: z.string().min(1),
69
+ observed_at: z.string().datetime(),
70
+ article: PublishedArticleStateSchema.extend({
71
+ hero_image_url: z.string().url().nullable(),
72
+ body_markdown: z.string().min(1).max(1_000_000),
73
+ description: z.string().nullable(),
74
+ byline: z.string().nullable(),
75
+ tags: z.array(z.string()),
76
+ }).strict(),
77
+ })
78
+ .strict()
79
+
59
80
  const PublishedInputsResponseSchema = z
60
81
  .object({
61
82
  ok: z.literal(true),
@@ -76,6 +97,54 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
76
97
  return json({ error: 'invalid_published_mode' }, 400)
77
98
  if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
78
99
  if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
100
+ const articleSlug = url.searchParams.get('article')
101
+ if (articleSlug !== null) {
102
+ const slug = z
103
+ .string()
104
+ .min(1)
105
+ .max(512)
106
+ .regex(/^[a-z0-9][a-z0-9-]*$/)
107
+ .safeParse(articleSlug)
108
+ if (!slug.success || url.searchParams.has('cursor') || url.searchParams.has('limit'))
109
+ return json({ error: 'invalid_article_source_request' }, 400)
110
+ try {
111
+ const content = await getPublishedContentBySlug(ctx.db, slug.data)
112
+ if (!content || content.type !== 'article')
113
+ return json({ error: 'published_article_missing' }, 404)
114
+ const item = content.payload.item
115
+ const heroImageUrl = await readReadyImagePublicUrl(ctx.db, item.hero_image_id)
116
+ const result = PublishedArticleSourceSchema.safeParse({
117
+ ok: true,
118
+ schema: 'cms.published-article-source.v1',
119
+ site: siteId,
120
+ observed_at: new Date().toISOString(),
121
+ article: {
122
+ content_id: content.contentId,
123
+ revision_id: content.revisionId,
124
+ slug: content.slug,
125
+ title: item.title,
126
+ canonical_url: item.canonical_url ?? null,
127
+ published_at: content.publishedAt,
128
+ source_id: item.source_id ?? null,
129
+ youtube_video_id: content.metadata.videoId ?? null,
130
+ hero_image_id: item.hero_image_id ?? null,
131
+ hero_image_url: heroImageUrl,
132
+ hero_image_caption: item.hero_image_caption ?? null,
133
+ body_markdown: content.payload.content?.body_markdown,
134
+ description: item.description ?? null,
135
+ byline: item.byline ?? null,
136
+ tags: content.payload.tags,
137
+ },
138
+ })
139
+ if (!result.success) return json({ error: 'published_article_source_invalid' }, 409)
140
+ if (new TextEncoder().encode(JSON.stringify(result.data)).byteLength > 4 << 20)
141
+ return json({ error: 'published_article_source_too_large' }, 409)
142
+ return json(result.data)
143
+ } catch (error) {
144
+ if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
145
+ throw error
146
+ }
147
+ }
79
148
  const limit = z.coerce
80
149
  .number()
81
150
  .int()
@@ -137,7 +206,7 @@ function resolveRunDate(ctx: RouteContext): string {
137
206
  }
138
207
 
139
208
  /**
140
- * Resolve the HMAC secret from ctx.env.INSIGHTS_INGEST_HMAC_SECRET.
209
+ * Resolve the explicit legacy ingest binding, or the declared shared CMS machine binding for channel publications.
141
210
  * Supports both:
142
211
  * - plain string (test injection: env: { INSIGHTS_INGEST_HMAC_SECRET: 'test-...' })
143
212
  * - Secrets Store binding object { get(): Promise<string> } (production Workers binding)
@@ -146,21 +215,13 @@ function resolveRunDate(ctx: RouteContext): string {
146
215
  async function resolveIngestSecret(
147
216
  env: Record<string, unknown> | undefined,
148
217
  ): Promise<string | null> {
149
- const raw = env?.INSIGHTS_INGEST_HMAC_SECRET
150
- if (typeof raw === 'string') return raw
151
- if (
152
- raw !== null &&
153
- typeof raw === 'object' &&
154
- typeof (raw as { get?: unknown }).get === 'function'
155
- ) {
156
- try {
157
- const val = await (raw as { get(): Promise<string> }).get()
158
- if (typeof val === 'string' && val.length > 0) return val
159
- } catch {
160
- return null
161
- }
162
- }
163
- return null
218
+ // Preserve explicit legacy ingest credentials, including their failures.
219
+ // Channel publications already declare the shared CMS machine binding.
220
+ const binding =
221
+ env?.INSIGHTS_INGEST_HMAC_SECRET === undefined
222
+ ? 'CMS_FOUNDRY_HMAC_SECRET'
223
+ : 'INSIGHTS_INGEST_HMAC_SECRET'
224
+ return resolveFoundryHmacSecretForInputs(env, binding)
164
225
  }
165
226
 
166
227
  export interface ContentInsightsRouteConfig extends CmsRouteConfig {
@@ -277,7 +338,13 @@ export function createContentInsightsRoutes(
277
338
  // HMAC-guarded machine ingest — NOT behind requirePublisher (sibling of foundryCallback).
278
339
  async ingest(ctx) {
279
340
  const hmacSecret = await resolveIngestSecret(ctx.env)
280
- if (!hmacSecret) return json({ error: 'INSIGHTS_INGEST_HMAC_SECRET is not configured' }, 500)
341
+ if (!hmacSecret)
342
+ return json(
343
+ {
344
+ error: `${ctx.env?.INSIGHTS_INGEST_HMAC_SECRET === undefined ? 'CMS_FOUNDRY_HMAC_SECRET' : 'INSIGHTS_INGEST_HMAC_SECRET'} is not configured`,
345
+ },
346
+ 500,
347
+ )
281
348
 
282
349
  let rawBody: string
283
350
  try {
@@ -235,6 +235,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
235
235
  videoSourceProductionEnabled?: boolean
236
236
  /** Internal machine-route constraint: atomically update only in this status. */
237
237
  updateStatusConstraint?: ContentStatus
238
+ /** Internal machine-route pin; joins the existing status-constrained atomic batch. */
239
+ updateCanonicalVersionConstraint?: number
238
240
  /**
239
241
  * Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
240
242
  * commit step (publishContent + createRevision) with the caller's — publishOne
@@ -1807,6 +1809,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1807
1809
  try {
1808
1810
  updated = await updateContentItem(ctx.db, id, parsed.data, {
1809
1811
  requiredStatus: config.updateStatusConstraint,
1812
+ expectedCanonicalVersion: config.updateCanonicalVersionConstraint,
1810
1813
  ...(expectedMediaSource ? { expectedMediaSource } : {}),
1811
1814
  })
1812
1815
  } catch (error) {
@@ -1821,6 +1824,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1821
1824
  if (updated.statusConflict) {
1822
1825
  return json({ error: 'update_status_conflict', state: updated.statusConflict }, 409)
1823
1826
  }
1827
+ if (updated.canonicalVersionConflict) {
1828
+ return json({ error: 'update_canonical_conflict' }, 409)
1829
+ }
1824
1830
  if (updated.mediaSourceConflict) {
1825
1831
  return json({ error: 'media_source_conflict' }, 409)
1826
1832
  }
@@ -1,5 +1,6 @@
1
1
  import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/foundry-signing'
2
2
  import { z } from 'zod'
3
+ import { ContentMetadataSchema, mergePinnedArticleMetadata } from '../engine/content-metadata.js'
3
4
  import { type PublicationReconciler, publishOne } from '../engine/publication.js'
4
5
  import { PublicationCurrentSnapshotError } from '../engine/publication-current-guard.js'
5
6
  import { PublishContentValidationError } from '../engine/publisher.js'
@@ -9,7 +10,7 @@ import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.j
9
10
  import { type ContentRouteConfig, createContentRoutes } from './content.js'
10
11
  import { json, type RouteContext } from './context.js'
11
12
  import { markFoundryMachineContext } from './machine-context.js'
12
- import { insertMediaAsset } from './media-lib.js'
13
+ import { insertMediaAsset, readReadyImagePublicUrl } from './media-lib.js'
13
14
 
14
15
  const SOURCE_PREFIX = 'foundry:'
15
16
 
@@ -117,7 +118,10 @@ async function authenticate(
117
118
  // host-bound foundry-cms-v2 string with this publication's CONFIGURED
118
119
  // canonical host when it opted in (M3-18), else the legacy dot string,
119
120
  // byte-for-byte as before.
120
- const path = new URL(ctx.request.url).pathname
121
+ const url = new URL(ctx.request.url)
122
+ const path =
123
+ url.pathname +
124
+ (ctx.request.method === 'GET' && url.searchParams.has('working') ? url.search : '')
121
125
  const verified = await verifyFoundryRequest(secret, {
122
126
  method: ctx.request.method,
123
127
  host: canonicalHost,
@@ -263,6 +267,9 @@ async function mapUpdateConflict(response: Response, id: string): Promise<Respon
263
267
  if (response.status !== 409) return response
264
268
  try {
265
269
  const payload = (await response.clone().json()) as { error?: unknown; state?: unknown }
270
+ if (payload.error === 'update_canonical_conflict') {
271
+ return json({ error: 'human_edit_pending', external_id: id }, 409)
272
+ }
266
273
  if (payload.error === 'update_status_conflict' && typeof payload.state === 'string') {
267
274
  return approvalConflict(id, payload.state)
268
275
  }
@@ -470,6 +477,7 @@ async function bindAdoption(
470
477
  config: ContentRouteConfig,
471
478
  id: string,
472
479
  candidate: ContentCandidate,
480
+ expectedVersion?: number,
473
481
  ): Promise<boolean> {
474
482
  const sourceId = `${SOURCE_PREFIX}${id}`
475
483
  await ctx.db.batch([
@@ -477,9 +485,17 @@ async function bindAdoption(
477
485
  .prepare(
478
486
  `UPDATE content_items SET source_id = ?, updated_at = unixepoch()
479
487
  WHERE id = ? AND deleted_at IS NULL AND type = 'article' AND source_id = ?
480
- AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)`,
488
+ AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)
489
+ AND (? IS NULL OR canonical_version = ?)`,
481
490
  )
482
- .bind(sourceId, candidate.id, candidate.source_id, candidate.id),
491
+ .bind(
492
+ sourceId,
493
+ candidate.id,
494
+ candidate.source_id,
495
+ candidate.id,
496
+ expectedVersion ?? null,
497
+ expectedVersion ?? null,
498
+ ),
483
499
  ctx.db
484
500
  .prepare(
485
501
  `INSERT INTO cms_activity_log (id, workspace_id, user_id, action, target_id, target_title, type)
@@ -518,6 +534,27 @@ async function canonicalVersion(ctx: RouteContext, contentId: string): Promise<n
518
534
  return row?.canonical_version ?? null
519
535
  }
520
536
 
537
+ /** A signed improvement names the actual immutable CMS revision it read. */
538
+ async function sourceRevisionVersion(
539
+ ctx: RouteContext,
540
+ contentId: string,
541
+ revisionId: string,
542
+ ): Promise<number | null> {
543
+ const revision = await getRevision(ctx.db, contentId, revisionId)
544
+ if (!revision) return null
545
+ const item = revision.payload.item as Record<string, unknown> | undefined
546
+ const version = item?.canonical_version
547
+ if (
548
+ item?.id !== contentId ||
549
+ item.type !== 'article' ||
550
+ typeof version !== 'number' ||
551
+ !Number.isSafeInteger(version) ||
552
+ version < 1
553
+ )
554
+ return null
555
+ return (await canonicalVersion(ctx, contentId)) === version ? version : null
556
+ }
557
+
521
558
  /**
522
559
  * Record what this v2 PUT wrote as a History revision (the autosave/restore
523
560
  * mechanism; it never moves the published pointer), created_by `foundry` and
@@ -571,6 +608,67 @@ async function lastFoundryWriteSnapshot(
571
608
  return revision ? { ...revision.payload } : null
572
609
  }
573
610
 
611
+ const WorkingArticleSourceSchema = z
612
+ .object({
613
+ schema: z.literal('cms.foundry-working-article.v1'),
614
+ content_id: z.string().min(1),
615
+ canonical_version: z.number().int().nonnegative(),
616
+ title: z.string().min(1),
617
+ description: z.string().nullable(),
618
+ slug: z.string().min(1),
619
+ body_html: z.string().min(1).max(2_000_000),
620
+ hero_image_id: z.string().nullable(),
621
+ hero_image_url: z.string().url().nullable(),
622
+ metadata: ContentMetadataSchema,
623
+ })
624
+ .strict()
625
+
626
+ async function workingArticleSource(ctx: RouteContext, row: FoundryArticleRow): Promise<Response> {
627
+ const snapshot = await lastFoundryWriteSnapshot(ctx, row)
628
+ if (!snapshot)
629
+ return json({ error: 'foundry_working_revision_changed', external_id: row.external_id }, 409)
630
+ const item = snapshot.item as Record<string, unknown>
631
+ const content = snapshot.content as Record<string, unknown>
632
+ let metadata: unknown
633
+ try {
634
+ metadata = JSON.parse(String(item.metadata_json ?? '{}'))
635
+ } catch {
636
+ return json({ error: 'working_source_metadata_invalid' }, 409)
637
+ }
638
+ const recorded = ContentMetadataSchema.safeParse(metadata)
639
+ if (!recorded.success) return json({ error: 'working_source_metadata_invalid' }, 409)
640
+ const allowed = new Set([
641
+ 'preparation',
642
+ 'seoDescription',
643
+ 'credits',
644
+ 'imageReview',
645
+ 'faq',
646
+ 'captionMoments',
647
+ 'videoId',
648
+ 'cmsSource',
649
+ 'strategyWork',
650
+ 'sourceRef',
651
+ 'sourceRevisionId',
652
+ 'adaptedAt',
653
+ 'updatedAt',
654
+ 'videoPublishedAt',
655
+ ])
656
+ const result = WorkingArticleSourceSchema.safeParse({
657
+ schema: 'cms.foundry-working-article.v1',
658
+ content_id: row.content_id,
659
+ canonical_version: item.canonical_version,
660
+ title: item.title,
661
+ description: item.description ?? null,
662
+ slug: item.slug,
663
+ body_html: content?.body_html,
664
+ hero_image_id: item.hero_image_id ?? null,
665
+ hero_image_url: await readReadyImagePublicUrl(ctx.db, item.hero_image_id),
666
+ metadata: Object.fromEntries(Object.entries(recorded.data).filter(([key]) => allowed.has(key))),
667
+ })
668
+ if (!result.success) return json({ error: 'foundry_working_source_invalid' }, 409)
669
+ return json({ ...v2Receipt(row), working: result.data })
670
+ }
671
+
574
672
  /** The v2 receipt: the four v1 fields plus the stored request hash. */
575
673
  function v2Receipt(
576
674
  row: Pick<FoundryArticleRow, 'external_id' | 'receipt_id' | 'status' | 'slug' | 'request_sha256'>,
@@ -595,6 +693,29 @@ async function putV2(
595
693
  const body = parsed.data
596
694
 
597
695
  const prior = await findArticle(ctx, id)
696
+ let expectedWorkingVersion: number | undefined
697
+ if (body.expectedRequestSHA256) {
698
+ if (
699
+ !prior ||
700
+ (prior.request_sha256 !== body.expectedRequestSHA256 && prior.request_sha256 !== bodyHash)
701
+ )
702
+ return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
703
+ const snapshot = await lastFoundryWriteSnapshot(ctx, prior)
704
+ if (!snapshot) return json({ error: 'human_edit_pending', external_id: id }, 409)
705
+ const version = (snapshot.item as Record<string, unknown>).canonical_version
706
+ // Idempotent replay still requires the exact machine snapshot to be current.
707
+ if (prior.request_sha256 !== bodyHash && version !== body.expectedCanonicalVersion)
708
+ return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
709
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1)
710
+ return json({ error: 'human_edit_pending', external_id: id }, 409)
711
+ expectedWorkingVersion = version
712
+ }
713
+ if (body.createOnly && prior && (prior.request_sha256 !== bodyHash || prior.status !== 'draft')) {
714
+ return json(
715
+ { error: 'article_already_exists', external_id: id, content_id: prior.content_id },
716
+ 409,
717
+ )
718
+ }
598
719
  if (prior?.request_sha256 === bodyHash) {
599
720
  console.info('[cms] foundry article request deduplicated', {
600
721
  externalId: id,
@@ -610,8 +731,41 @@ async function putV2(
610
731
  // Scheduled, review and archived items stay human-owned.
611
732
  if (bound && !V2_WRITABLE.has(bound.status)) return approvalConflict(id, bound.status)
612
733
 
734
+ if (body.createOnly) {
735
+ if (body.expectedRevisionId) return json({ error: 'create_only_has_source_revision' }, 400)
736
+ if (body.adoptSourceId || bound)
737
+ return json({ error: 'article_already_exists', external_id: id }, 409)
738
+ const slugHolder = await findCandidate(ctx, 'slug', body.slug)
739
+ if (slugHolder) return slugConflict(id, body.slug, slugHolder.id)
740
+ const videoId = body.metadata?.videoId
741
+ if (typeof videoId !== 'string' || !/^[A-Za-z0-9_-]{11}$/.test(videoId))
742
+ return json({ error: 'create_only_requires_source_video' }, 400)
743
+ // Check the actual CMS working and immutable published projections, not
744
+ // just Foundry's own relationship ledger. A creation cannot adopt or edit.
745
+ const associated = await ctx.db
746
+ .prepare(`SELECT c.id FROM content_items c
747
+ LEFT JOIN content_revisions r ON r.id=c.published_revision_id AND r.content_id=c.id
748
+ WHERE c.type='article' AND c.deleted_at IS NULL AND (
749
+ CASE WHEN json_valid(c.metadata_json) THEN json_extract(c.metadata_json,'$.videoId') END = ?
750
+ OR CASE WHEN json_valid(json_extract(r.payload_json,'$.item.metadata_json')) THEN json_extract(json_extract(r.payload_json,'$.item.metadata_json'),'$.videoId') END = ?
751
+ ) LIMIT 1`)
752
+ .bind(videoId, videoId)
753
+ .first<{ id: string }>()
754
+ if (associated)
755
+ return json(
756
+ { error: 'article_video_already_exists', external_id: id, content_id: associated.id },
757
+ 409,
758
+ )
759
+ }
613
760
  let adoption: Adoption | null = null
761
+ let expectedSourceVersion: number | undefined = expectedWorkingVersion
614
762
  if (bound) {
763
+ if (body.expectedRevisionId) {
764
+ const version = await sourceRevisionVersion(ctx, bound.id, body.expectedRevisionId)
765
+ if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
766
+ expectedSourceVersion = version
767
+ }
768
+
615
769
  const holder = await findCandidate(ctx, 'slug', body.slug)
616
770
  if (holder && holder.id !== bound.id) return slugConflict(id, body.slug, holder.id)
617
771
  } else {
@@ -619,7 +773,12 @@ async function putV2(
619
773
  if ('error' in resolved) return resolved.error
620
774
  if (resolved.candidate && resolved.matchedBy) {
621
775
  const candidate = resolved.candidate
622
- if (!(await bindAdoption(ctx, config, id, candidate))) {
776
+ if (body.expectedRevisionId) {
777
+ const version = await sourceRevisionVersion(ctx, candidate.id, body.expectedRevisionId)
778
+ if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
779
+ expectedSourceVersion = version
780
+ }
781
+ if (!(await bindAdoption(ctx, config, id, candidate, expectedSourceVersion))) {
623
782
  return json(
624
783
  {
625
784
  error: 'adoption_conflict',
@@ -656,11 +815,44 @@ async function putV2(
656
815
  }
657
816
  }
658
817
 
659
- // The canonical version Foundry's write must produce is one over this.
660
- const priorVersion = bound ? await canonicalVersion(ctx, bound.id) : null
818
+ // Preserve a newer human working copy before PUT, not merely at publication.
819
+ // The native update pins this same canonical version in its atomic batch.
820
+ if (body.expectedRevisionId && !bound)
821
+ return json({ error: 'source_revision_missing', external_id: id }, 409)
822
+ const priorVersion =
823
+ expectedSourceVersion ?? (bound ? await canonicalVersion(ctx, bound.id) : null)
661
824
  const heroImageId = body.hero_image_url
662
825
  ? await heroAssetId(body.hero_image_url)
663
826
  : (body.hero_image_key ?? undefined)
827
+ // A pinned improvement augments its exact source metadata. Existing dates,
828
+ // image provenance and other recorded fields survive preparation updates.
829
+ let metadata = body.metadata
830
+ if (
831
+ (body.expectedRevisionId || body.expectedRequestSHA256) &&
832
+ body.metadata !== undefined &&
833
+ bound
834
+ ) {
835
+ const source = body.expectedRevisionId
836
+ ? (await getRevision(ctx.db, bound.id, body.expectedRevisionId))?.payload
837
+ : prior
838
+ ? await lastFoundryWriteSnapshot(ctx, prior)
839
+ : null
840
+ let original: unknown
841
+ try {
842
+ original = JSON.parse(
843
+ String((source?.item as Record<string, unknown> | undefined)?.metadata_json ?? '{}'),
844
+ )
845
+ } catch {
846
+ return json({ error: 'source_metadata_invalid', external_id: id }, 409)
847
+ }
848
+ const recorded = ContentMetadataSchema.safeParse(original)
849
+ if (!recorded.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
850
+ const merged = ContentMetadataSchema.safeParse(
851
+ mergePinnedArticleMetadata(recorded.data, body.metadata),
852
+ )
853
+ if (!merged.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
854
+ metadata = merged.data
855
+ }
664
856
  const nativeBody = {
665
857
  title: body.title,
666
858
  slug: body.slug,
@@ -672,7 +864,7 @@ async function putV2(
672
864
  ...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
673
865
  ...(body.tags !== undefined ? { tags: body.tags } : {}),
674
866
  ...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
675
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
867
+ ...(metadata !== undefined ? { metadata } : {}),
676
868
  content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
677
869
  }
678
870
 
@@ -681,7 +873,12 @@ async function putV2(
681
873
  // Each write is a CAS on the status it was read in: a draft stays a
682
874
  // draft write, and a published item is edited in place only while it is
683
875
  // still published (its live revision changes only on POST /publish).
684
- const writer = bound.status === 'published' ? handlers.published : handlers.draft
876
+ if (priorVersion === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
877
+ const writer = createContentRoutes({
878
+ ...machineConfig(config),
879
+ updateStatusConstraint: bound.status,
880
+ updateCanonicalVersionConstraint: priorVersion,
881
+ })
685
882
  nativeResponse = await mapUpdateConflict(
686
883
  await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
687
884
  id,
@@ -698,6 +895,8 @@ async function putV2(
698
895
  )
699
896
  } catch (error) {
700
897
  const raced = await findContentBySource(ctx, id)
898
+ if (body.createOnly && raced)
899
+ return json({ error: 'article_already_exists', external_id: id, content_id: raced.id }, 409)
701
900
  if (raced?.type !== 'article') throw error
702
901
  nativeResponse = await mapUpdateConflict(
703
902
  await handlers.draft.update(nativeContext(ctx, 'PATCH', nativeBody, { id: raced.id })),
@@ -1028,6 +1227,13 @@ export function createFoundryPublishRoutes(
1028
1227
  if ('error' in auth) return auth.error
1029
1228
  const row = await findArticle(ctx, id)
1030
1229
  if (!row) return json({ error: 'not_found' }, 404)
1230
+ const working = new URL(ctx.request.url).searchParams.get('working')
1231
+ if (working !== null) {
1232
+ if (working !== '1') return json({ error: 'invalid_working_source_request' }, 400)
1233
+ if (!autoPublishEnabled(config))
1234
+ return json({ error: 'native_working_source_unavailable' }, 409)
1235
+ return workingArticleSource(ctx, row)
1236
+ }
1031
1237
  console.info('[cms] foundry article read', { externalId: id, receiptId: row.receipt_id })
1032
1238
  if (autoPublishEnabled(config)) return json(v2Receipt(row))
1033
1239
  return json(receipt(row))
@@ -60,7 +60,10 @@ import { json, type RouteContext } from './context.js'
60
60
  // family rather than inventing a second one.
61
61
  export async function resolveFoundryHmacSecret(
62
62
  env: Record<string, unknown> | undefined,
63
- binding: 'FOUNDRY_HMAC_SECRET' | 'CMS_FOUNDRY_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
63
+ binding:
64
+ | 'FOUNDRY_HMAC_SECRET'
65
+ | 'CMS_FOUNDRY_HMAC_SECRET'
66
+ | 'INSIGHTS_INGEST_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
64
67
  ): Promise<string | null> {
65
68
  const raw = env?.[binding]
66
69
  if (typeof raw === 'string') return raw || null
@@ -9,6 +9,7 @@
9
9
  // The `media.fronts.co/cdn-cgi/image` transform doc-comment + editorial-max
10
10
  // constants are theme-token — kept here as documentation only.
11
11
 
12
+ import { z } from 'zod'
12
13
  import type { D1Database } from '../engine/d1.js'
13
14
  import { slugify } from '../engine/slug.js'
14
15
 
@@ -18,6 +19,23 @@ import { slugify } from '../engine/slug.js'
18
19
 
19
20
  const MEDIA_DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i
20
21
 
22
+ /** Resolve the selected ready image; a legacy key or unavailable asset stays unknown. */
23
+ export async function readReadyImagePublicUrl(db: D1Database, id: unknown): Promise<string | null> {
24
+ if (typeof id !== 'string' || id === '') return null
25
+ const asset = await db
26
+ .prepare(
27
+ "SELECT public_url FROM media_assets WHERE id = ? AND asset_type = 'image' AND status = 'ready'",
28
+ )
29
+ .bind(id)
30
+ .first<{ public_url: string | null }>()
31
+ const url = z.string().url().safeParse(asset?.public_url)
32
+ if (!url.success) return null
33
+ const parsed = new URL(url.data)
34
+ return parsed.protocol === 'https:' && !parsed.username && !parsed.password && !parsed.hash
35
+ ? url.data
36
+ : null
37
+ }
38
+
21
39
  /**
22
40
  * Resolve the public media domain, preferring `env.MEDIA_PUBLIC_DOMAIN` when it
23
41
  * looks like a valid host, else the site-configured default. Allows
@@ -33,7 +33,7 @@ import {
33
33
  applyMediaSourceReadiness,
34
34
  buildContentCreatePayload,
35
35
  buildContentUpdatePayload,
36
- canCreateContentDraft,
36
+ contentDraftSaveError,
37
37
  foundryDispatchSourceForDraft,
38
38
  initialContentMetadata,
39
39
  type MediaSourceReadinessPayload,
@@ -406,19 +406,14 @@ export function ContentForm({
406
406
  const draft = currentForm
407
407
 
408
408
  if (id === null) {
409
- if (
410
- !canCreateContentDraft(contentType, draft, {
411
- requirePrimaryTopic: primaryTopicEnabled,
412
- })
413
- ) {
414
- const mediaSource = foundryDispatchSourceForDraft(contentType, draft)
415
- const missingMediaDuration =
416
- mediaSource && normalizeMediaDurationSeconds(draft.durationSeconds) === null
409
+ const saveError = contentDraftSaveError(contentType, draft, {
410
+ requirePrimaryTopic: primaryTopicEnabled,
411
+ primaryTopicLabel: primaryTopic?.label,
412
+ })
413
+ if (saveError) {
417
414
  setForm((prev) => ({
418
415
  ...prev,
419
- saveError: missingMediaDuration
420
- ? 'Enter the authoritative media duration in whole seconds before saving.'
421
- : 'Add the required title and content before saving.',
416
+ saveError,
422
417
  }))
423
418
  return
424
419
  }
@@ -543,7 +538,7 @@ export function ContentForm({
543
538
  }
544
539
  }
545
540
  },
546
- [contentType, onDocCreated, primaryTopicEnabled, queueFoundryProcessing],
541
+ [contentType, onDocCreated, primaryTopicEnabled, primaryTopic?.label, queueFoundryProcessing],
547
542
  )
548
543
 
549
544
  useEffect(() => {
@@ -156,6 +156,23 @@ export function foundryDispatchSourceForDraft(
156
156
  return null
157
157
  }
158
158
 
159
+ /** Explain the same prerequisites that gate the first save, using the host's field label. */
160
+ export function contentDraftSaveError(
161
+ contentType: ContentType,
162
+ draft: ContentDraftFields,
163
+ requirements: { requirePrimaryTopic?: boolean; primaryTopicLabel?: string } = {},
164
+ ): string | null {
165
+ if (canCreateContentDraft(contentType, draft, requirements)) return null
166
+ if (requirements.requirePrimaryTopic && !draft.primaryTopic?.trim()) {
167
+ return `Select ${requirements.primaryTopicLabel || 'a primary topic'} before saving.`
168
+ }
169
+ const mediaSource = foundryDispatchSourceForDraft(contentType, draft)
170
+ if (mediaSource && normalizeMediaDurationSeconds(draft.durationSeconds) === null) {
171
+ return 'Enter the authoritative media duration in whole seconds before saving.'
172
+ }
173
+ return 'Add the required title and content before saving.'
174
+ }
175
+
159
176
  export function inferVideoSourceKind(sourceUrl: string): 'dropbox' | 'http_url' {
160
177
  try {
161
178
  const { hostname } = new URL(sourceUrl)