@growth-labs/cms 0.8.32 → 0.8.35

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 (49) hide show
  1. package/README.md +20 -0
  2. package/dist/engine/content-metadata.d.ts +1 -0
  3. package/dist/engine/content-metadata.d.ts.map +1 -1
  4. package/dist/engine/content-metadata.js +32 -0
  5. package/dist/engine/content-metadata.js.map +1 -1
  6. package/dist/engine/publication-current-guard.d.ts +3 -0
  7. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  8. package/dist/engine/publication-current-guard.js +19 -4
  9. package/dist/engine/publication-current-guard.js.map +1 -1
  10. package/dist/engine/publication.d.ts +3 -1
  11. package/dist/engine/publication.d.ts.map +1 -1
  12. package/dist/engine/publication.js +11 -2
  13. package/dist/engine/publication.js.map +1 -1
  14. package/dist/engine/publisher.d.ts +4 -0
  15. package/dist/engine/publisher.d.ts.map +1 -1
  16. package/dist/engine/publisher.js +25 -9
  17. package/dist/engine/publisher.js.map +1 -1
  18. package/dist/foundry-emit.d.ts +11 -0
  19. package/dist/foundry-emit.d.ts.map +1 -1
  20. package/dist/foundry-emit.js +3 -0
  21. package/dist/foundry-emit.js.map +1 -1
  22. package/dist/routes/content-insights.d.ts.map +1 -1
  23. package/dist/routes/content-insights.js +76 -1
  24. package/dist/routes/content-insights.js.map +1 -1
  25. package/dist/routes/content.d.ts +2 -0
  26. package/dist/routes/content.d.ts.map +1 -1
  27. package/dist/routes/content.js +4 -0
  28. package/dist/routes/content.js.map +1 -1
  29. package/dist/routes/foundry-publish.d.ts.map +1 -1
  30. package/dist/routes/foundry-publish.js +146 -28
  31. package/dist/routes/foundry-publish.js.map +1 -1
  32. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  33. package/dist/ui/editor/ContentForm.js +7 -9
  34. package/dist/ui/editor/ContentForm.js.map +1 -1
  35. package/dist/ui/editor/content-payload.d.ts +5 -0
  36. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  37. package/dist/ui/editor/content-payload.js +13 -0
  38. package/dist/ui/editor/content-payload.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/engine/content-metadata.ts +35 -0
  41. package/src/engine/publication-current-guard.ts +28 -4
  42. package/src/engine/publication.ts +17 -4
  43. package/src/engine/publisher.ts +33 -7
  44. package/src/foundry-emit.ts +3 -0
  45. package/src/routes/content-insights.ts +79 -1
  46. package/src/routes/content.ts +6 -0
  47. package/src/routes/foundry-publish.ts +163 -33
  48. package/src/ui/editor/ContentForm.tsx +8 -13
  49. package/src/ui/editor/content-payload.ts +17 -0
@@ -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'
@@ -56,6 +60,22 @@ const PublishedArticleStateSchema = z
56
60
  })
57
61
  .strict()
58
62
 
63
+ const PublishedArticleSourceSchema = z
64
+ .object({
65
+ ok: z.literal(true),
66
+ schema: z.literal('cms.published-article-source.v1'),
67
+ site: z.string().min(1),
68
+ observed_at: z.string().datetime(),
69
+ article: PublishedArticleStateSchema.extend({
70
+ hero_image_url: z.string().url().nullable(),
71
+ body_markdown: z.string().min(1).max(1_000_000),
72
+ description: z.string().nullable(),
73
+ byline: z.string().nullable(),
74
+ tags: z.array(z.string()),
75
+ }).strict(),
76
+ })
77
+ .strict()
78
+
59
79
  const PublishedInputsResponseSchema = z
60
80
  .object({
61
81
  ok: z.literal(true),
@@ -76,6 +96,64 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
76
96
  return json({ error: 'invalid_published_mode' }, 400)
77
97
  if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
78
98
  if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
99
+ const articleSlug = url.searchParams.get('article')
100
+ if (articleSlug !== null) {
101
+ const slug = z
102
+ .string()
103
+ .min(1)
104
+ .max(512)
105
+ .regex(/^[a-z0-9][a-z0-9-]*$/)
106
+ .safeParse(articleSlug)
107
+ if (!slug.success || url.searchParams.has('cursor') || url.searchParams.has('limit'))
108
+ return json({ error: 'invalid_article_source_request' }, 400)
109
+ try {
110
+ const content = await getPublishedContentBySlug(ctx.db, slug.data)
111
+ if (!content || content.type !== 'article')
112
+ return json({ error: 'published_article_missing' }, 404)
113
+ const item = content.payload.item
114
+ let heroImageUrl: string | null = null
115
+ if (item.hero_image_id) {
116
+ const asset = await ctx.db
117
+ .prepare(
118
+ "SELECT public_url FROM media_assets WHERE id = ? AND asset_type = 'image' AND status = 'ready'",
119
+ )
120
+ .bind(item.hero_image_id)
121
+ .first<{ public_url: string | null }>()
122
+ const publicUrl = z.string().url().safeParse(asset?.public_url)
123
+ if (publicUrl.success) heroImageUrl = publicUrl.data
124
+ }
125
+ const result = PublishedArticleSourceSchema.safeParse({
126
+ ok: true,
127
+ schema: 'cms.published-article-source.v1',
128
+ site: siteId,
129
+ observed_at: new Date().toISOString(),
130
+ article: {
131
+ content_id: content.contentId,
132
+ revision_id: content.revisionId,
133
+ slug: content.slug,
134
+ title: item.title,
135
+ canonical_url: item.canonical_url ?? null,
136
+ published_at: content.publishedAt,
137
+ source_id: item.source_id ?? null,
138
+ youtube_video_id: content.metadata.videoId ?? null,
139
+ hero_image_id: item.hero_image_id ?? null,
140
+ hero_image_url: heroImageUrl,
141
+ hero_image_caption: item.hero_image_caption ?? null,
142
+ body_markdown: content.payload.content?.body_markdown,
143
+ description: item.description ?? null,
144
+ byline: item.byline ?? null,
145
+ tags: content.payload.tags,
146
+ },
147
+ })
148
+ if (!result.success) return json({ error: 'published_article_source_invalid' }, 409)
149
+ if (new TextEncoder().encode(JSON.stringify(result.data)).byteLength > 4 << 20)
150
+ return json({ error: 'published_article_source_too_large' }, 409)
151
+ return json(result.data)
152
+ } catch (error) {
153
+ if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
154
+ throw error
155
+ }
156
+ }
79
157
  const limit = z.coerce
80
158
  .number()
81
159
  .int()
@@ -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,8 +1,10 @@
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'
5
+ import { PublicationCurrentSnapshotError } from '../engine/publication-current-guard.js'
4
6
  import { PublishContentValidationError } from '../engine/publisher.js'
5
- import { createRevisionWithDelta } from '../engine/revisions.js'
7
+ import { createRevisionWithDelta, getRevision } from '../engine/revisions.js'
6
8
  import { buildContentPath, slugify } from '../engine/slug.js'
7
9
  import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.js'
8
10
  import { type ContentRouteConfig, createContentRoutes } from './content.js'
@@ -20,6 +22,10 @@ const PutSchema = z
20
22
  })
21
23
  .strict()
22
24
 
25
+ const PinnedPublishSchema = z
26
+ .object({ expected_request_sha256: z.string().regex(/^[a-f0-9]{64}$/) })
27
+ .strict()
28
+
23
29
  interface FoundryArticleRow {
24
30
  external_id: string
25
31
  content_id: string
@@ -258,6 +264,9 @@ async function mapUpdateConflict(response: Response, id: string): Promise<Respon
258
264
  if (response.status !== 409) return response
259
265
  try {
260
266
  const payload = (await response.clone().json()) as { error?: unknown; state?: unknown }
267
+ if (payload.error === 'update_canonical_conflict') {
268
+ return json({ error: 'human_edit_pending', external_id: id }, 409)
269
+ }
261
270
  if (payload.error === 'update_status_conflict' && typeof payload.state === 'string') {
262
271
  return approvalConflict(id, payload.state)
263
272
  }
@@ -465,6 +474,7 @@ async function bindAdoption(
465
474
  config: ContentRouteConfig,
466
475
  id: string,
467
476
  candidate: ContentCandidate,
477
+ expectedVersion?: number,
468
478
  ): Promise<boolean> {
469
479
  const sourceId = `${SOURCE_PREFIX}${id}`
470
480
  await ctx.db.batch([
@@ -472,9 +482,17 @@ async function bindAdoption(
472
482
  .prepare(
473
483
  `UPDATE content_items SET source_id = ?, updated_at = unixepoch()
474
484
  WHERE id = ? AND deleted_at IS NULL AND type = 'article' AND source_id = ?
475
- AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)`,
485
+ AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)
486
+ AND (? IS NULL OR canonical_version = ?)`,
476
487
  )
477
- .bind(sourceId, candidate.id, candidate.source_id, candidate.id),
488
+ .bind(
489
+ sourceId,
490
+ candidate.id,
491
+ candidate.source_id,
492
+ candidate.id,
493
+ expectedVersion ?? null,
494
+ expectedVersion ?? null,
495
+ ),
478
496
  ctx.db
479
497
  .prepare(
480
498
  `INSERT INTO cms_activity_log (id, workspace_id, user_id, action, target_id, target_title, type)
@@ -491,6 +509,8 @@ async function bindAdoption(
491
509
  }
492
510
 
493
511
  function publishFailure(id: string, error: unknown): Response {
512
+ if (error instanceof PublicationCurrentSnapshotError)
513
+ return json({ error: 'human_edit_pending', external_id: id }, 409)
494
514
  if (error instanceof PublishContentValidationError) {
495
515
  return json({ error: 'publish_validation_failed', external_id: id, errors: error.errors }, 422)
496
516
  }
@@ -511,6 +531,27 @@ async function canonicalVersion(ctx: RouteContext, contentId: string): Promise<n
511
531
  return row?.canonical_version ?? null
512
532
  }
513
533
 
534
+ /** A signed improvement names the actual immutable CMS revision it read. */
535
+ async function sourceRevisionVersion(
536
+ ctx: RouteContext,
537
+ contentId: string,
538
+ revisionId: string,
539
+ ): Promise<number | null> {
540
+ const revision = await getRevision(ctx.db, contentId, revisionId)
541
+ if (!revision) return null
542
+ const item = revision.payload.item as Record<string, unknown> | undefined
543
+ const version = item?.canonical_version
544
+ if (
545
+ item?.id !== contentId ||
546
+ item.type !== 'article' ||
547
+ typeof version !== 'number' ||
548
+ !Number.isSafeInteger(version) ||
549
+ version < 1
550
+ )
551
+ return null
552
+ return (await canonicalVersion(ctx, contentId)) === version ? version : null
553
+ }
554
+
514
555
  /**
515
556
  * Record what this v2 PUT wrote as a History revision (the autosave/restore
516
557
  * mechanism; it never moves the published pointer), created_by `foundry` and
@@ -545,29 +586,23 @@ async function recordFoundryWrite(
545
586
  }
546
587
  }
547
588
 
548
- /** True while the working copy is still exactly what Foundry's last v2 PUT wrote. */
549
- async function lastFoundryWriteCurrent(
589
+ /** The immutable last PUT snapshot, only while its canonical version is current. */
590
+ async function lastFoundryWriteSnapshot(
550
591
  ctx: RouteContext,
551
592
  row: FoundryArticleRow,
552
- ): Promise<boolean> {
553
- const versions = await ctx.db
554
- .prepare(
555
- `SELECT c.canonical_version AS current_version,
556
- (SELECT json_extract(r.payload_json, '$.item.canonical_version')
557
- FROM content_revisions r
558
- WHERE r.content_id = c.id AND r.created_by = 'foundry' AND r.tag = ?
559
- ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1) AS put_version
560
- FROM content_items c
561
- WHERE c.id = ? AND c.deleted_at IS NULL
562
- LIMIT 1`,
563
- )
564
- .bind(`${FOUNDRY_PUT_TAG}${row.request_sha256}`, row.content_id)
565
- .first<{ current_version: number | null; put_version: number | null }>()
566
- return (
567
- versions !== null &&
568
- versions.put_version !== null &&
569
- versions.current_version === versions.put_version
570
- )
593
+ ): Promise<Record<string, unknown> | null> {
594
+ const match = await ctx.db
595
+ .prepare(`SELECT r.id AS revision_id
596
+ FROM content_items c JOIN content_revisions r ON r.content_id = c.id
597
+ WHERE c.id = ? AND c.deleted_at IS NULL AND r.created_by = 'foundry' AND r.tag = ?
598
+ AND c.canonical_version IS NOT NULL
599
+ AND c.canonical_version = json_extract(r.payload_json, '$.item.canonical_version')
600
+ ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1`)
601
+ .bind(row.content_id, `${FOUNDRY_PUT_TAG}${row.request_sha256}`)
602
+ .first<{ revision_id: string }>()
603
+ if (!match) return null
604
+ const revision = await getRevision(ctx.db, row.content_id, match.revision_id)
605
+ return revision ? { ...revision.payload } : null
571
606
  }
572
607
 
573
608
  /** The v2 receipt: the four v1 fields plus the stored request hash. */
@@ -594,6 +629,12 @@ async function putV2(
594
629
  const body = parsed.data
595
630
 
596
631
  const prior = await findArticle(ctx, id)
632
+ if (body.createOnly && prior && (prior.request_sha256 !== bodyHash || prior.status !== 'draft')) {
633
+ return json(
634
+ { error: 'article_already_exists', external_id: id, content_id: prior.content_id },
635
+ 409,
636
+ )
637
+ }
597
638
  if (prior?.request_sha256 === bodyHash) {
598
639
  console.info('[cms] foundry article request deduplicated', {
599
640
  externalId: id,
@@ -609,8 +650,41 @@ async function putV2(
609
650
  // Scheduled, review and archived items stay human-owned.
610
651
  if (bound && !V2_WRITABLE.has(bound.status)) return approvalConflict(id, bound.status)
611
652
 
653
+ if (body.createOnly) {
654
+ if (body.expectedRevisionId) return json({ error: 'create_only_has_source_revision' }, 400)
655
+ if (body.adoptSourceId || bound)
656
+ return json({ error: 'article_already_exists', external_id: id }, 409)
657
+ const slugHolder = await findCandidate(ctx, 'slug', body.slug)
658
+ if (slugHolder) return slugConflict(id, body.slug, slugHolder.id)
659
+ const videoId = body.metadata?.videoId
660
+ if (typeof videoId !== 'string' || !/^[A-Za-z0-9_-]{11}$/.test(videoId))
661
+ return json({ error: 'create_only_requires_source_video' }, 400)
662
+ // Check the actual CMS working and immutable published projections, not
663
+ // just Foundry's own relationship ledger. A creation cannot adopt or edit.
664
+ const associated = await ctx.db
665
+ .prepare(`SELECT c.id FROM content_items c
666
+ LEFT JOIN content_revisions r ON r.id=c.published_revision_id AND r.content_id=c.id
667
+ WHERE c.type='article' AND c.deleted_at IS NULL AND (
668
+ CASE WHEN json_valid(c.metadata_json) THEN json_extract(c.metadata_json,'$.videoId') END = ?
669
+ 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 = ?
670
+ ) LIMIT 1`)
671
+ .bind(videoId, videoId)
672
+ .first<{ id: string }>()
673
+ if (associated)
674
+ return json(
675
+ { error: 'article_video_already_exists', external_id: id, content_id: associated.id },
676
+ 409,
677
+ )
678
+ }
612
679
  let adoption: Adoption | null = null
680
+ let expectedSourceVersion: number | undefined
613
681
  if (bound) {
682
+ if (body.expectedRevisionId) {
683
+ const version = await sourceRevisionVersion(ctx, bound.id, body.expectedRevisionId)
684
+ if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
685
+ expectedSourceVersion = version
686
+ }
687
+
614
688
  const holder = await findCandidate(ctx, 'slug', body.slug)
615
689
  if (holder && holder.id !== bound.id) return slugConflict(id, body.slug, holder.id)
616
690
  } else {
@@ -618,7 +692,12 @@ async function putV2(
618
692
  if ('error' in resolved) return resolved.error
619
693
  if (resolved.candidate && resolved.matchedBy) {
620
694
  const candidate = resolved.candidate
621
- if (!(await bindAdoption(ctx, config, id, candidate))) {
695
+ if (body.expectedRevisionId) {
696
+ const version = await sourceRevisionVersion(ctx, candidate.id, body.expectedRevisionId)
697
+ if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
698
+ expectedSourceVersion = version
699
+ }
700
+ if (!(await bindAdoption(ctx, config, id, candidate, expectedSourceVersion))) {
622
701
  return json(
623
702
  {
624
703
  error: 'adoption_conflict',
@@ -655,11 +734,34 @@ async function putV2(
655
734
  }
656
735
  }
657
736
 
658
- // The canonical version Foundry's write must produce is one over this.
659
- const priorVersion = bound ? await canonicalVersion(ctx, bound.id) : null
737
+ // Preserve a newer human working copy before PUT, not merely at publication.
738
+ // The native update pins this same canonical version in its atomic batch.
739
+ if (body.expectedRevisionId && !bound)
740
+ return json({ error: 'source_revision_missing', external_id: id }, 409)
741
+ const priorVersion =
742
+ expectedSourceVersion ?? (bound ? await canonicalVersion(ctx, bound.id) : null)
660
743
  const heroImageId = body.hero_image_url
661
744
  ? await heroAssetId(body.hero_image_url)
662
745
  : (body.hero_image_key ?? undefined)
746
+ // A pinned improvement augments its exact source metadata. Existing dates,
747
+ // image provenance and other recorded fields survive preparation updates.
748
+ let metadata = body.metadata
749
+ if (body.expectedRevisionId && body.metadata !== undefined && bound) {
750
+ const source = await getRevision(ctx.db, bound.id, body.expectedRevisionId)
751
+ let original: unknown
752
+ try {
753
+ original = JSON.parse(String(source?.payload.item?.metadata_json ?? '{}'))
754
+ } catch {
755
+ return json({ error: 'source_metadata_invalid', external_id: id }, 409)
756
+ }
757
+ const recorded = ContentMetadataSchema.safeParse(original)
758
+ if (!recorded.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
759
+ const merged = ContentMetadataSchema.safeParse(
760
+ mergePinnedArticleMetadata(recorded.data, body.metadata),
761
+ )
762
+ if (!merged.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
763
+ metadata = merged.data
764
+ }
663
765
  const nativeBody = {
664
766
  title: body.title,
665
767
  slug: body.slug,
@@ -671,7 +773,7 @@ async function putV2(
671
773
  ...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
672
774
  ...(body.tags !== undefined ? { tags: body.tags } : {}),
673
775
  ...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
674
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
776
+ ...(metadata !== undefined ? { metadata } : {}),
675
777
  content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
676
778
  }
677
779
 
@@ -680,7 +782,12 @@ async function putV2(
680
782
  // Each write is a CAS on the status it was read in: a draft stays a
681
783
  // draft write, and a published item is edited in place only while it is
682
784
  // still published (its live revision changes only on POST /publish).
683
- const writer = bound.status === 'published' ? handlers.published : handlers.draft
785
+ if (priorVersion === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
786
+ const writer = createContentRoutes({
787
+ ...machineConfig(config),
788
+ updateStatusConstraint: bound.status,
789
+ updateCanonicalVersionConstraint: priorVersion,
790
+ })
684
791
  nativeResponse = await mapUpdateConflict(
685
792
  await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
686
793
  id,
@@ -697,6 +804,8 @@ async function putV2(
697
804
  )
698
805
  } catch (error) {
699
806
  const raced = await findContentBySource(ctx, id)
807
+ if (body.createOnly && raced)
808
+ return json({ error: 'article_already_exists', external_id: id, content_id: raced.id }, 409)
700
809
  if (raced?.type !== 'article') throw error
701
810
  nativeResponse = await mapUpdateConflict(
702
811
  await handlers.draft.update(nativeContext(ctx, 'PATCH', nativeBody, { id: raced.id })),
@@ -777,7 +886,8 @@ async function publishV2(
777
886
  // Publish, schedule and resync alike act on the working copy only while it is
778
887
  // still exactly what Foundry's last v2 PUT wrote: a human edit since then (to
779
888
  // a draft or a published item) is never machine-published or machine-scheduled.
780
- if (!(await lastFoundryWriteCurrent(ctx, row))) {
889
+ const expectedSnapshot = await lastFoundryWriteSnapshot(ctx, row)
890
+ if (!expectedSnapshot) {
781
891
  return json({ error: 'human_edit_pending', external_id: id }, 409)
782
892
  }
783
893
 
@@ -833,6 +943,8 @@ async function publishV2(
833
943
  now: input.now,
834
944
  publishedAt,
835
945
  createdBy: 'foundry',
946
+ expectedFoundryArticleSnapshot: expectedSnapshot,
947
+ expectedReceiptIdentity: row.request_sha256,
836
948
  surfaces,
837
949
  ...(config.foundryPublish?.requiredSurfaceNames
838
950
  ? { requiredSurfaceNames: config.foundryPublish.requiredSurfaceNames }
@@ -1060,14 +1172,32 @@ export function createFoundryPublishRoutes(
1060
1172
  const rawBody = await ctx.request.text()
1061
1173
  const auth = await verify(ctx, rawBody)
1062
1174
  if ('error' in auth) return auth.error
1063
- // POST /publish takes no body: anything sent is refused, never ignored.
1064
- if (rawBody.length > 0 || !/^0?$/.test(ctx.request.headers.get('Content-Length') ?? '')) {
1065
- return json({ error: 'invalid_request', detail: 'publish takes no request body' }, 400)
1175
+ // Legacy callers publish the current write. Strategy execution pins the
1176
+ // exact PUT bytes in the signed body; an unrelated replacement is refused.
1177
+ let expectedHash: string | undefined
1178
+ if (rawBody.length > 0) {
1179
+ let value: unknown
1180
+ try {
1181
+ value = JSON.parse(rawBody)
1182
+ } catch {
1183
+ return json({ error: 'invalid_request' }, 400)
1184
+ }
1185
+ const parsed = PinnedPublishSchema.safeParse(value)
1186
+ if (!parsed.success) return json({ error: 'invalid_request' }, 400)
1187
+ expectedHash = parsed.data.expected_request_sha256
1188
+ } else if (!/^0?$/.test(ctx.request.headers.get('Content-Length') ?? '')) {
1189
+ return json(
1190
+ { error: 'invalid_request', detail: 'empty publish request declares a body' },
1191
+ 400,
1192
+ )
1066
1193
  }
1067
1194
  const row = await findArticle(ctx, id)
1068
1195
  if (!row) return json({ error: 'not_found' }, 404)
1069
1196
  // Without the publication's opt-in the machine route stays draft-only.
1070
1197
  if (!autoPublishEnabled(config)) return approvalConflict(id, row.status)
1198
+ if (expectedHash !== undefined && expectedHash !== row.request_sha256) {
1199
+ return json({ error: 'article_revision_changed', external_id: id }, 409)
1200
+ }
1071
1201
  return publishV2(ctx, config, content, id, row)
1072
1202
  },
1073
1203
  }
@@ -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)