@growth-labs/cms 0.8.35 → 0.8.40

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 (87) hide show
  1. package/dist/engine/fronts-publish.d.ts +29 -0
  2. package/dist/engine/fronts-publish.d.ts.map +1 -1
  3. package/dist/engine/fronts-publish.js +58 -0
  4. package/dist/engine/fronts-publish.js.map +1 -1
  5. package/dist/engine/publication-current-guard.d.ts +19 -0
  6. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  7. package/dist/engine/publication-current-guard.js +75 -0
  8. package/dist/engine/publication-current-guard.js.map +1 -1
  9. package/dist/engine/publisher.d.ts +3 -0
  10. package/dist/engine/publisher.d.ts.map +1 -1
  11. package/dist/engine/publisher.js +15 -2
  12. package/dist/engine/publisher.js.map +1 -1
  13. package/dist/foundry-emit.d.ts +106 -4
  14. package/dist/foundry-emit.d.ts.map +1 -1
  15. package/dist/foundry-emit.js +28 -0
  16. package/dist/foundry-emit.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts.map +1 -1
  18. package/dist/routes/content-insights.js +18 -28
  19. package/dist/routes/content-insights.js.map +1 -1
  20. package/dist/routes/content.d.ts +3 -0
  21. package/dist/routes/content.d.ts.map +1 -1
  22. package/dist/routes/content.js +1 -0
  23. package/dist/routes/content.js.map +1 -1
  24. package/dist/routes/foundry-publish.d.ts.map +1 -1
  25. package/dist/routes/foundry-publish.js +191 -8
  26. package/dist/routes/foundry-publish.js.map +1 -1
  27. package/dist/routes/fronts-publish.d.ts +3 -1
  28. package/dist/routes/fronts-publish.d.ts.map +1 -1
  29. package/dist/routes/fronts-publish.js +47 -1
  30. package/dist/routes/fronts-publish.js.map +1 -1
  31. package/dist/routes/media-lib.d.ts +4 -0
  32. package/dist/routes/media-lib.d.ts.map +1 -1
  33. package/dist/routes/media-lib.js +23 -0
  34. package/dist/routes/media-lib.js.map +1 -1
  35. package/dist/schema/insights-ingest.d.ts +88 -88
  36. package/dist/ui/api/fronts/publish-callback.d.ts +1 -0
  37. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
  38. package/dist/ui/api/fronts/publish-callback.js +1 -0
  39. package/dist/ui/api/fronts/publish-callback.js.map +1 -1
  40. package/dist/ui/editor/extensions.d.ts +1 -1
  41. package/dist/ui/editor/extensions.d.ts.map +1 -1
  42. package/dist/ui/editor/extensions.js +15 -1
  43. package/dist/ui/editor/extensions.js.map +1 -1
  44. package/dist/ui/editor/heading-level.d.ts +4 -0
  45. package/dist/ui/editor/heading-level.d.ts.map +1 -0
  46. package/dist/ui/editor/heading-level.js +13 -0
  47. package/dist/ui/editor/heading-level.js.map +1 -0
  48. package/dist/ui/editor/portable-text.d.ts.map +1 -1
  49. package/dist/ui/editor/portable-text.js +3 -2
  50. package/dist/ui/editor/portable-text.js.map +1 -1
  51. package/dist/ui/editor/serialize.d.ts.map +1 -1
  52. package/dist/ui/editor/serialize.js +4 -3
  53. package/dist/ui/editor/serialize.js.map +1 -1
  54. package/dist/ui/preview/SitePreviewFrame.d.ts +11 -0
  55. package/dist/ui/preview/SitePreviewFrame.d.ts.map +1 -0
  56. package/dist/ui/preview/SitePreviewFrame.js +86 -0
  57. package/dist/ui/preview/SitePreviewFrame.js.map +1 -0
  58. package/dist/ui/preview/preview-layout.d.ts +6 -0
  59. package/dist/ui/preview/preview-layout.d.ts.map +1 -1
  60. package/dist/ui/preview/preview-layout.js +15 -0
  61. package/dist/ui/preview/preview-layout.js.map +1 -1
  62. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  63. package/dist/ui/screens/EditorScreen.js +61 -25
  64. package/dist/ui/screens/EditorScreen.js.map +1 -1
  65. package/dist/ui/screens/public-url.d.ts +12 -0
  66. package/dist/ui/screens/public-url.d.ts.map +1 -1
  67. package/dist/ui/screens/public-url.js +30 -0
  68. package/dist/ui/screens/public-url.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/engine/fronts-publish.ts +88 -0
  71. package/src/engine/publication-current-guard.ts +103 -0
  72. package/src/engine/publisher.ts +22 -3
  73. package/src/foundry-emit.ts +36 -0
  74. package/src/routes/content-insights.ts +23 -28
  75. package/src/routes/content.ts +4 -0
  76. package/src/routes/foundry-publish.ts +228 -8
  77. package/src/routes/fronts-publish.ts +61 -1
  78. package/src/routes/media-lib.ts +24 -0
  79. package/src/ui/api/fronts/publish-callback.ts +3 -0
  80. package/src/ui/editor/extensions.ts +16 -1
  81. package/src/ui/editor/heading-level.ts +14 -0
  82. package/src/ui/editor/portable-text.ts +3 -2
  83. package/src/ui/editor/serialize.ts +4 -3
  84. package/src/ui/preview/SitePreviewFrame.tsx +111 -0
  85. package/src/ui/preview/preview-layout.ts +17 -0
  86. package/src/ui/screens/EditorScreen.tsx +76 -27
  87. package/src/ui/screens/public-url.ts +36 -0
@@ -25,6 +25,7 @@ import {
25
25
  resolveFoundryHmacSecret as resolveFoundryHmacSecretForInputs,
26
26
  verifyFoundrySignedRequest,
27
27
  } from './fronts-publish.js'
28
+ import { readReadyImagePublicUrl } from './media-lib.js'
28
29
 
29
30
  const DismissSchema = z
30
31
  .object({
@@ -67,6 +68,10 @@ const PublishedArticleSourceSchema = z
67
68
  site: z.string().min(1),
68
69
  observed_at: z.string().datetime(),
69
70
  article: PublishedArticleStateSchema.extend({
71
+ publication_receipt_identity: z
72
+ .string()
73
+ .regex(/^[a-f0-9]{64}$/)
74
+ .nullable(),
70
75
  hero_image_url: z.string().url().nullable(),
71
76
  body_markdown: z.string().min(1).max(1_000_000),
72
77
  description: z.string().nullable(),
@@ -111,17 +116,8 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
111
116
  if (!content || content.type !== 'article')
112
117
  return json({ error: 'published_article_missing' }, 404)
113
118
  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
- }
119
+ const snapshot = content.payload as unknown as Record<string, unknown>
120
+ const heroImageUrl = await readReadyImagePublicUrl(ctx.db, item.hero_image_id)
125
121
  const result = PublishedArticleSourceSchema.safeParse({
126
122
  ok: true,
127
123
  schema: 'cms.published-article-source.v1',
@@ -130,6 +126,7 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
130
126
  article: {
131
127
  content_id: content.contentId,
132
128
  revision_id: content.revisionId,
129
+ publication_receipt_identity: snapshot.publication_receipt_identity ?? null,
133
130
  slug: content.slug,
134
131
  title: item.title,
135
132
  canonical_url: item.canonical_url ?? null,
@@ -215,7 +212,7 @@ function resolveRunDate(ctx: RouteContext): string {
215
212
  }
216
213
 
217
214
  /**
218
- * Resolve the HMAC secret from ctx.env.INSIGHTS_INGEST_HMAC_SECRET.
215
+ * Resolve the explicit legacy ingest binding, or the declared shared CMS machine binding for channel publications.
219
216
  * Supports both:
220
217
  * - plain string (test injection: env: { INSIGHTS_INGEST_HMAC_SECRET: 'test-...' })
221
218
  * - Secrets Store binding object { get(): Promise<string> } (production Workers binding)
@@ -224,21 +221,13 @@ function resolveRunDate(ctx: RouteContext): string {
224
221
  async function resolveIngestSecret(
225
222
  env: Record<string, unknown> | undefined,
226
223
  ): Promise<string | null> {
227
- const raw = env?.INSIGHTS_INGEST_HMAC_SECRET
228
- if (typeof raw === 'string') return raw
229
- if (
230
- raw !== null &&
231
- typeof raw === 'object' &&
232
- typeof (raw as { get?: unknown }).get === 'function'
233
- ) {
234
- try {
235
- const val = await (raw as { get(): Promise<string> }).get()
236
- if (typeof val === 'string' && val.length > 0) return val
237
- } catch {
238
- return null
239
- }
240
- }
241
- return null
224
+ // Preserve explicit legacy ingest credentials, including their failures.
225
+ // Channel publications already declare the shared CMS machine binding.
226
+ const binding =
227
+ env?.INSIGHTS_INGEST_HMAC_SECRET === undefined
228
+ ? 'CMS_FOUNDRY_HMAC_SECRET'
229
+ : 'INSIGHTS_INGEST_HMAC_SECRET'
230
+ return resolveFoundryHmacSecretForInputs(env, binding)
242
231
  }
243
232
 
244
233
  export interface ContentInsightsRouteConfig extends CmsRouteConfig {
@@ -355,7 +344,13 @@ export function createContentInsightsRoutes(
355
344
  // HMAC-guarded machine ingest — NOT behind requirePublisher (sibling of foundryCallback).
356
345
  async ingest(ctx) {
357
346
  const hmacSecret = await resolveIngestSecret(ctx.env)
358
- if (!hmacSecret) return json({ error: 'INSIGHTS_INGEST_HMAC_SECRET is not configured' }, 500)
347
+ if (!hmacSecret)
348
+ return json(
349
+ {
350
+ error: `${ctx.env?.INSIGHTS_INGEST_HMAC_SECRET === undefined ? 'CMS_FOUNDRY_HMAC_SECRET' : 'INSIGHTS_INGEST_HMAC_SECRET'} is not configured`,
351
+ },
352
+ 500,
353
+ )
359
354
 
360
355
  let rawBody: string
361
356
  try {
@@ -35,6 +35,7 @@ import {
35
35
  setFrontsChannelTargets,
36
36
  } from '../engine/fronts-publish-intent.js'
37
37
  import { classifyGoogleDriveUrl } from '../engine/google-drive-source.js'
38
+ import type { NativeArticleDraftSnapshot } from '../engine/publication-current-guard.js'
38
39
  import { evaluateArticleBody } from '../engine/publish-guard.js'
39
40
  import {
40
41
  createContent,
@@ -237,6 +238,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
237
238
  updateStatusConstraint?: ContentStatus
238
239
  /** Internal machine-route pin; joins the existing status-constrained atomic batch. */
239
240
  updateCanonicalVersionConstraint?: number
241
+ /** Exact native draft input; joins the status/version constrained batch. */
242
+ updateNativeDraftSnapshotConstraint?: NativeArticleDraftSnapshot
240
243
  /**
241
244
  * Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
242
245
  * commit step (publishContent + createRevision) with the caller's — publishOne
@@ -1810,6 +1813,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1810
1813
  updated = await updateContentItem(ctx.db, id, parsed.data, {
1811
1814
  requiredStatus: config.updateStatusConstraint,
1812
1815
  expectedCanonicalVersion: config.updateCanonicalVersionConstraint,
1816
+ expectedNativeDraftSnapshot: config.updateNativeDraftSnapshotConstraint,
1813
1817
  ...(expectedMediaSource ? { expectedMediaSource } : {}),
1814
1818
  })
1815
1819
  } catch (error) {
@@ -2,15 +2,22 @@ import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/f
2
2
  import { z } from 'zod'
3
3
  import { ContentMetadataSchema, mergePinnedArticleMetadata } from '../engine/content-metadata.js'
4
4
  import { type PublicationReconciler, publishOne } from '../engine/publication.js'
5
- import { PublicationCurrentSnapshotError } from '../engine/publication-current-guard.js'
6
- import { PublishContentValidationError } from '../engine/publisher.js'
5
+ import {
6
+ type NativeArticleDraftSnapshot,
7
+ PublicationCurrentSnapshotError,
8
+ } from '../engine/publication-current-guard.js'
9
+ import {
10
+ canonicalJsonSha256,
11
+ type JsonValue,
12
+ } from '../engine/publication-receipt/canonical-json.js'
13
+ import { getContentSnapshot, PublishContentValidationError } from '../engine/publisher.js'
7
14
  import { createRevisionWithDelta, getRevision } from '../engine/revisions.js'
8
15
  import { buildContentPath, slugify } from '../engine/slug.js'
9
16
  import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.js'
10
17
  import { type ContentRouteConfig, createContentRoutes } from './content.js'
11
18
  import { json, type RouteContext } from './context.js'
12
19
  import { markFoundryMachineContext } from './machine-context.js'
13
- import { insertMediaAsset } from './media-lib.js'
20
+ import { insertMediaAsset, readReadyImagePublicUrl, readyImagePublicUrl } from './media-lib.js'
14
21
 
15
22
  const SOURCE_PREFIX = 'foundry:'
16
23
 
@@ -118,7 +125,10 @@ async function authenticate(
118
125
  // host-bound foundry-cms-v2 string with this publication's CONFIGURED
119
126
  // canonical host when it opted in (M3-18), else the legacy dot string,
120
127
  // byte-for-byte as before.
121
- const path = new URL(ctx.request.url).pathname
128
+ const url = new URL(ctx.request.url)
129
+ const path =
130
+ url.pathname +
131
+ (ctx.request.method === 'GET' && url.searchParams.has('working') ? url.search : '')
122
132
  const verified = await verifyFoundryRequest(secret, {
123
133
  method: ctx.request.method,
124
134
  host: canonicalHost,
@@ -605,6 +615,147 @@ async function lastFoundryWriteSnapshot(
605
615
  return revision ? { ...revision.payload } : null
606
616
  }
607
617
 
618
+ const WorkingArticleSourceSchema = z
619
+ .object({
620
+ schema: z.literal('cms.foundry-working-article.v1'),
621
+ content_id: z.string().min(1),
622
+ canonical_version: z.number().int().nonnegative(),
623
+ title: z.string().min(1),
624
+ description: z.string().nullable(),
625
+ slug: z.string().min(1),
626
+ body_html: z.string().min(1).max(2_000_000),
627
+ hero_image_id: z.string().nullable(),
628
+ hero_image_url: z.string().url().nullable(),
629
+ metadata: ContentMetadataSchema,
630
+ })
631
+ .strict()
632
+
633
+ async function workingArticleSource(ctx: RouteContext, row: FoundryArticleRow): Promise<Response> {
634
+ const snapshot = await lastFoundryWriteSnapshot(ctx, row)
635
+ if (!snapshot)
636
+ return json({ error: 'foundry_working_revision_changed', external_id: row.external_id }, 409)
637
+ const projection = await projectArticleSource(ctx, row, snapshot)
638
+ if (projection instanceof Response) return projection
639
+ return json({ ...v2Receipt(row), working: projection })
640
+ }
641
+
642
+ async function projectArticleSource(
643
+ ctx: RouteContext,
644
+ row: FoundryArticleRow,
645
+ snapshot: Record<string, unknown>,
646
+ ): Promise<z.infer<typeof WorkingArticleSourceSchema> | Response> {
647
+ const item = snapshot.item as Record<string, unknown>
648
+ const content = snapshot.content as Record<string, unknown>
649
+ let metadata: unknown
650
+ try {
651
+ metadata = JSON.parse(String(item.metadata_json ?? '{}'))
652
+ } catch {
653
+ return json({ error: 'working_source_metadata_invalid' }, 409)
654
+ }
655
+ const recorded = ContentMetadataSchema.safeParse(metadata)
656
+ if (!recorded.success) return json({ error: 'working_source_metadata_invalid' }, 409)
657
+ const allowed = new Set([
658
+ 'preparation',
659
+ 'seoDescription',
660
+ 'credits',
661
+ 'imageReview',
662
+ 'faq',
663
+ 'captionMoments',
664
+ 'videoId',
665
+ 'cmsSource',
666
+ 'strategyWork',
667
+ 'sourceRef',
668
+ 'sourceRevisionId',
669
+ 'adaptedAt',
670
+ 'updatedAt',
671
+ 'videoPublishedAt',
672
+ ])
673
+ const result = WorkingArticleSourceSchema.safeParse({
674
+ schema: 'cms.foundry-working-article.v1',
675
+ content_id: row.content_id,
676
+ canonical_version: item.canonical_version,
677
+ title: item.title,
678
+ description: item.description ?? null,
679
+ slug: item.slug,
680
+ body_html: content?.body_html,
681
+ hero_image_id: item.hero_image_id ?? null,
682
+ hero_image_url: Object.hasOwn(snapshot, 'hero_asset')
683
+ ? readyImagePublicUrl(snapshot.hero_asset as Record<string, unknown> | null)
684
+ : await readReadyImagePublicUrl(ctx.db, item.hero_image_id),
685
+ metadata: Object.fromEntries(Object.entries(recorded.data).filter(([key]) => allowed.has(key))),
686
+ })
687
+ if (!result.success) return json({ error: 'foundry_working_source_invalid' }, 409)
688
+ return result.data
689
+ }
690
+
691
+ /** Current input for governed preparation, never evidence of an earlier PUT. */
692
+ async function draftPreparationSnapshot(
693
+ ctx: RouteContext,
694
+ row: FoundryArticleRow,
695
+ ): Promise<Record<string, unknown> | null> {
696
+ if (row.status !== 'draft') return null
697
+ const snapshot = await getContentSnapshot(ctx.db, row.content_id)
698
+ const item = snapshot?.item as Record<string, unknown> | undefined
699
+ if (
700
+ !(
701
+ snapshot &&
702
+ item?.id === row.content_id &&
703
+ item.type === 'article' &&
704
+ item.status === 'draft' &&
705
+ item.deleted_at == null &&
706
+ item.source_id === `${SOURCE_PREFIX}${row.external_id}` &&
707
+ typeof item.canonical_version === 'number' &&
708
+ Number.isSafeInteger(item.canonical_version) &&
709
+ item.canonical_version > 0
710
+ )
711
+ )
712
+ return null
713
+ const hero =
714
+ typeof item.hero_image_id === 'string'
715
+ ? await ctx.db
716
+ .prepare('SELECT * FROM media_assets WHERE id=? LIMIT 1')
717
+ .bind(item.hero_image_id)
718
+ .first<Record<string, unknown>>()
719
+ : null
720
+ return { ...snapshot, hero_asset: hero }
721
+ }
722
+
723
+ async function draftPreparationSource(
724
+ ctx: RouteContext,
725
+ row: FoundryArticleRow,
726
+ ): Promise<Response> {
727
+ const snapshot = await draftPreparationSnapshot(ctx, row)
728
+ if (!snapshot) return json({ error: 'draft_preparation_source_unavailable' }, 409)
729
+ const projection = await projectArticleSource(ctx, row, snapshot)
730
+ if (projection instanceof Response) return projection
731
+ return json({
732
+ ...v2Receipt(row),
733
+ draft_preparation: {
734
+ ...projection,
735
+ schema: 'cms.foundry-draft-preparation.v1',
736
+ source_id: `${SOURCE_PREFIX}${row.external_id}`,
737
+ snapshot_sha256: await canonicalJsonSha256(
738
+ nativeDraftInput(snapshot, row) as unknown as JsonValue,
739
+ ),
740
+ },
741
+ })
742
+ }
743
+
744
+ function nativeDraftInput(
745
+ snapshot: Record<string, unknown>,
746
+ row: FoundryArticleRow,
747
+ ): NativeArticleDraftSnapshot {
748
+ return {
749
+ snapshot,
750
+ binding: {
751
+ externalId: row.external_id,
752
+ contentId: row.content_id,
753
+ receiptId: row.receipt_id,
754
+ requestSHA256: row.request_sha256,
755
+ },
756
+ }
757
+ }
758
+
608
759
  /** The v2 receipt: the four v1 fields plus the stored request hash. */
609
760
  function v2Receipt(
610
761
  row: Pick<FoundryArticleRow, 'external_id' | 'receipt_id' | 'status' | 'slug' | 'request_sha256'>,
@@ -629,6 +780,51 @@ async function putV2(
629
780
  const body = parsed.data
630
781
 
631
782
  const prior = await findArticle(ctx, id)
783
+ let expectedWorkingVersion: number | undefined
784
+ let preparedDraftSnapshot: Record<string, unknown> | null = null
785
+ if (body.expectedDraftSnapshot) {
786
+ const pin = body.expectedDraftSnapshot
787
+ // A replay is read-only and still requires this exact PUT's snapshot.
788
+ if (prior?.request_sha256 === bodyHash) {
789
+ if (!(await lastFoundryWriteSnapshot(ctx, prior)))
790
+ return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
791
+ return json(v2Receipt(prior))
792
+ }
793
+ if (
794
+ !prior ||
795
+ prior.content_id !== pin.contentId ||
796
+ prior.request_sha256 !== pin.priorRequestSHA256
797
+ )
798
+ return json({ error: 'draft_preparation_source_changed', external_id: id }, 409)
799
+ preparedDraftSnapshot = await draftPreparationSnapshot(ctx, prior)
800
+ const item = preparedDraftSnapshot?.item as Record<string, unknown> | undefined
801
+ if (
802
+ !preparedDraftSnapshot ||
803
+ item?.canonical_version !== pin.canonicalVersion ||
804
+ item?.slug !== body.slug ||
805
+ (await canonicalJsonSha256(
806
+ nativeDraftInput(preparedDraftSnapshot, prior) as unknown as JsonValue,
807
+ )) !== pin.sha256
808
+ )
809
+ return json({ error: 'draft_preparation_source_changed', external_id: id }, 409)
810
+ expectedWorkingVersion = pin.canonicalVersion
811
+ }
812
+ if (body.expectedRequestSHA256) {
813
+ if (
814
+ !prior ||
815
+ (prior.request_sha256 !== body.expectedRequestSHA256 && prior.request_sha256 !== bodyHash)
816
+ )
817
+ return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
818
+ const snapshot = await lastFoundryWriteSnapshot(ctx, prior)
819
+ if (!snapshot) return json({ error: 'human_edit_pending', external_id: id }, 409)
820
+ const version = (snapshot.item as Record<string, unknown>).canonical_version
821
+ // Idempotent replay still requires the exact machine snapshot to be current.
822
+ if (prior.request_sha256 !== bodyHash && version !== body.expectedCanonicalVersion)
823
+ return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
824
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1)
825
+ return json({ error: 'human_edit_pending', external_id: id }, 409)
826
+ expectedWorkingVersion = version
827
+ }
632
828
  if (body.createOnly && prior && (prior.request_sha256 !== bodyHash || prior.status !== 'draft')) {
633
829
  return json(
634
830
  { error: 'article_already_exists', external_id: id, content_id: prior.content_id },
@@ -677,7 +873,7 @@ async function putV2(
677
873
  )
678
874
  }
679
875
  let adoption: Adoption | null = null
680
- let expectedSourceVersion: number | undefined
876
+ let expectedSourceVersion: number | undefined = expectedWorkingVersion
681
877
  if (bound) {
682
878
  if (body.expectedRevisionId) {
683
879
  const version = await sourceRevisionVersion(ctx, bound.id, body.expectedRevisionId)
@@ -746,11 +942,23 @@ async function putV2(
746
942
  // A pinned improvement augments its exact source metadata. Existing dates,
747
943
  // image provenance and other recorded fields survive preparation updates.
748
944
  let metadata = body.metadata
749
- if (body.expectedRevisionId && body.metadata !== undefined && bound) {
750
- const source = await getRevision(ctx.db, bound.id, body.expectedRevisionId)
945
+ if (
946
+ (body.expectedRevisionId || body.expectedRequestSHA256 || body.expectedDraftSnapshot) &&
947
+ body.metadata !== undefined &&
948
+ bound
949
+ ) {
950
+ const source =
951
+ preparedDraftSnapshot ??
952
+ (body.expectedRevisionId
953
+ ? (await getRevision(ctx.db, bound.id, body.expectedRevisionId))?.payload
954
+ : prior
955
+ ? await lastFoundryWriteSnapshot(ctx, prior)
956
+ : null)
751
957
  let original: unknown
752
958
  try {
753
- original = JSON.parse(String(source?.payload.item?.metadata_json ?? '{}'))
959
+ original = JSON.parse(
960
+ String((source?.item as Record<string, unknown> | undefined)?.metadata_json ?? '{}'),
961
+ )
754
962
  } catch {
755
963
  return json({ error: 'source_metadata_invalid', external_id: id }, 409)
756
964
  }
@@ -787,6 +995,9 @@ async function putV2(
787
995
  ...machineConfig(config),
788
996
  updateStatusConstraint: bound.status,
789
997
  updateCanonicalVersionConstraint: priorVersion,
998
+ ...(preparedDraftSnapshot && prior
999
+ ? { updateNativeDraftSnapshotConstraint: nativeDraftInput(preparedDraftSnapshot, prior) }
1000
+ : {}),
790
1001
  })
791
1002
  nativeResponse = await mapUpdateConflict(
792
1003
  await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
@@ -1136,6 +1347,15 @@ export function createFoundryPublishRoutes(
1136
1347
  if ('error' in auth) return auth.error
1137
1348
  const row = await findArticle(ctx, id)
1138
1349
  if (!row) return json({ error: 'not_found' }, 404)
1350
+ const working = new URL(ctx.request.url).searchParams.get('working')
1351
+ if (working !== null) {
1352
+ if (working !== '1' && working !== 'prepare')
1353
+ return json({ error: 'invalid_working_source_request' }, 400)
1354
+ if (!autoPublishEnabled(config))
1355
+ return json({ error: 'native_working_source_unavailable' }, 409)
1356
+ if (working === 'prepare') return draftPreparationSource(ctx, row)
1357
+ return workingArticleSource(ctx, row)
1358
+ }
1139
1359
  console.info('[cms] foundry article read', { externalId: id, receiptId: row.receipt_id })
1140
1360
  if (autoPublishEnabled(config)) return json(v2Receipt(row))
1141
1361
  return json(receipt(row))
@@ -30,6 +30,7 @@ import {
30
30
  applyFrontsPublishCallback,
31
31
  isFrontsPublishEnabled,
32
32
  listFrontsPublishSchedule,
33
+ readFrontsPublishObservation,
33
34
  } from '../engine/fronts-publish.js'
34
35
  import {
35
36
  backfillFrontsChannelTargets,
@@ -60,7 +61,10 @@ import { json, type RouteContext } from './context.js'
60
61
  // family rather than inventing a second one.
61
62
  export async function resolveFoundryHmacSecret(
62
63
  env: Record<string, unknown> | undefined,
63
- binding: 'FOUNDRY_HMAC_SECRET' | 'CMS_FOUNDRY_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
64
+ binding:
65
+ | 'FOUNDRY_HMAC_SECRET'
66
+ | 'CMS_FOUNDRY_HMAC_SECRET'
67
+ | 'INSIGHTS_INGEST_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
64
68
  ): Promise<string | null> {
65
69
  const raw = env?.[binding]
66
70
  if (typeof raw === 'string') return raw || null
@@ -222,6 +226,8 @@ async function runScheduleMaintenance(
222
226
  }
223
227
 
224
228
  export interface FrontsPublishRouteHandlers {
229
+ /** GET /fronts/publish-callback — exact authenticated receiver/commit observation. */
230
+ readPublishCallback(ctx: RouteContext): Promise<Response>
225
231
  /** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
226
232
  schedule(ctx: RouteContext): Promise<Response>
227
233
  /** POST /fronts/schedule — bounded target arming or host-owned maintenance. */
@@ -257,6 +263,60 @@ function parseWindow(
257
263
  export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
258
264
  const resolved = resolveConfig(config)
259
265
  return {
266
+ async readPublishCallback(ctx) {
267
+ const secret = await resolveFoundryHmacSecret(ctx.env)
268
+ if (!secret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
269
+ const url = new URL(ctx.request.url)
270
+ const payload = `foundry.fronts-publish-observation.v1\nGET\n${url.pathname}\n${url.search}`
271
+ const denied = await verifyFoundrySignedRequest(
272
+ ctx.request,
273
+ secret,
274
+ payload,
275
+ 'fronts/publish-callback.read',
276
+ )
277
+ if (denied) return denied
278
+ if (ctx.request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405)
279
+ const keys = [
280
+ 'event_id',
281
+ 'intent_id',
282
+ 'content_id',
283
+ 'version',
284
+ 'publish_at',
285
+ 'status',
286
+ 'receipt_sha256',
287
+ ]
288
+ if (
289
+ [...url.searchParams.keys()].some((key) => !keys.includes(key)) ||
290
+ keys.some((key) => url.searchParams.getAll(key).length !== 1)
291
+ )
292
+ return json({ error: 'invalid_observation_binding' }, 400)
293
+ const values = Object.fromEntries(url.searchParams)
294
+ if (
295
+ ['event_id', 'intent_id', 'content_id'].some(
296
+ (key) => !values[key] || values[key]!.length > 200,
297
+ ) ||
298
+ !/^\d+$/.test(values.version!) ||
299
+ !Number.isSafeInteger(Number(values.version)) ||
300
+ Number(values.version) < 1 ||
301
+ !/^\d+$/.test(values.publish_at!) ||
302
+ !Number.isSafeInteger(Number(values.publish_at)) ||
303
+ !['publishing', 'live'].includes(values.status!) ||
304
+ (values.status === 'live'
305
+ ? !/^[0-9a-f]{64}$/.test(values.receipt_sha256!)
306
+ : values.receipt_sha256 !== '')
307
+ )
308
+ return json({ error: 'invalid_observation_binding' }, 400)
309
+ const result = await readFrontsPublishObservation(ctx.db, {
310
+ eventId: values.event_id!,
311
+ intentId: values.intent_id!,
312
+ contentId: values.content_id!,
313
+ version: Number(values.version),
314
+ publishAt: Number(values.publish_at),
315
+ status: values.status as 'publishing' | 'live',
316
+ receiptSHA256: values.receipt_sha256!,
317
+ })
318
+ return json(result, result.state === 'conflict' ? 409 : 200)
319
+ },
260
320
  async maintainSchedule(ctx) {
261
321
  let responseOperation: 'arm-channel-targets' | FrontsScheduleMaintenanceOperation =
262
322
  'arm-channel-targets'
@@ -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,29 @@ 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
+ return readyImagePublicUrl(asset ? { ...asset, asset_type: 'image', status: 'ready' } : null)
32
+ }
33
+
34
+ /** The same projection from an already captured asset row. */
35
+ export function readyImagePublicUrl(asset: Record<string, unknown> | null): string | null {
36
+ if (!asset || asset.asset_type !== 'image' || asset.status !== 'ready') return null
37
+ const url = z.string().url().safeParse(asset?.public_url)
38
+ if (!url.success) return null
39
+ const parsed = new URL(url.data)
40
+ return parsed.protocol === 'https:' && !parsed.username && !parsed.password && !parsed.hash
41
+ ? url.data
42
+ : null
43
+ }
44
+
21
45
  /**
22
46
  * Resolve the public media domain, preferring `env.MEDIA_PUBLIC_DOMAIN` when it
23
47
  * looks like a valid host, else the site-configured default. Allows
@@ -10,3 +10,6 @@ import { toContentConfig, toContentCtx } from '../_content-config.js'
10
10
 
11
11
  export const POST = (c: APIContext) =>
12
12
  createFrontsPublishRoutes(toContentConfig(c)).publishCallback(toContentCtx(c, {}))
13
+
14
+ export const GET = (c: APIContext) =>
15
+ createFrontsPublishRoutes(toContentConfig(c)).readPublishCallback(toContentCtx(c, {}))
@@ -1,13 +1,27 @@
1
1
  import { DragHandle } from '@tiptap/extension-drag-handle'
2
+ import Heading from '@tiptap/extension-heading'
2
3
  import Image from '@tiptap/extension-image'
3
4
  import Link from '@tiptap/extension-link'
4
5
  import { TableKit } from '@tiptap/extension-table'
5
6
  import StarterKit from '@tiptap/starter-kit'
6
7
  import { Callout, Citation, GeneratedFaq, PullQuote } from './blocks.js'
8
+ import { BODY_HEADING_LEVELS, bodyHeadingLevel } from './heading-level.js'
7
9
  import { SlashMenu } from './slash-menu.js'
8
10
  import { TrimBoundaryMarks } from './trim-boundary-marks.js'
9
11
  import { TweetEmbed } from './tweet-embed.js'
10
12
 
13
+ /** StarterKit's heading restricted to the body levels. Every pasted h1–h6
14
+ * parses onto the nearest body level instead of dropping to a paragraph, so a
15
+ * document's Heading 1 chapter titles arrive as H2. */
16
+ const BodyHeading = Heading.extend({
17
+ parseHTML() {
18
+ return [1, 2, 3, 4, 5, 6].map((level) => ({
19
+ tag: `h${level}`,
20
+ attrs: { level: bodyHeadingLevel(level) },
21
+ }))
22
+ },
23
+ }).configure({ levels: [...BODY_HEADING_LEVELS] })
24
+
11
25
  function renderDragHandle(): HTMLElement {
12
26
  const handle = document.createElement('div')
13
27
  handle.className = 'drag-handle'
@@ -25,7 +39,8 @@ function renderDragHandle(): HTMLElement {
25
39
  * silently drops underline styling on paste — Word documents come in clean. */
26
40
  export function createRteExtensions() {
27
41
  return [
28
- StarterKit.configure({ link: false, underline: false }),
42
+ StarterKit.configure({ link: false, underline: false, heading: false }),
43
+ BodyHeading,
29
44
  Link.configure({ openOnClick: false }),
30
45
  TrimBoundaryMarks,
31
46
  Image,
@@ -0,0 +1,14 @@
1
+ // src/ui/editor/heading-level.ts — PURE (no Tiptap import; serializers use it).
2
+ // Body heading levels. The page title is the article's only H1, so a body
3
+ // Heading 1 (typed as `# ` or pasted from a document) is a chapter title and
4
+ // becomes an H2; h5/h6 fold into H4, the deepest level the public prose styles.
5
+
6
+ export const BODY_HEADING_LEVELS = [2, 3, 4] as const
7
+
8
+ export type BodyHeadingLevel = (typeof BODY_HEADING_LEVELS)[number]
9
+
10
+ export function bodyHeadingLevel(level: number): BodyHeadingLevel {
11
+ if (!Number.isFinite(level) || level <= 2) return 2
12
+ if (level >= 4) return 4
13
+ return 3
14
+ }
@@ -20,6 +20,7 @@
20
20
  import type { JSONContent } from '@tiptap/core'
21
21
  import type { PortableTextObject } from '../../schema/portable-text.js'
22
22
  import { normalizeBoundaryMarks } from './boundary-marks.js'
23
+ import { bodyHeadingLevel } from './heading-level.js'
23
24
 
24
25
  type KeyGenerator = () => string
25
26
 
@@ -78,7 +79,7 @@ function serializeBlockNode(
78
79
  out.push(buildTextBlock('normal', node.content ?? [], nextKey, list))
79
80
  return
80
81
  case 'heading': {
81
- const level = (node.attrs?.level as number) ?? 2
82
+ const level = bodyHeadingLevel((node.attrs?.level as number) ?? 2)
82
83
  out.push(buildTextBlock(`h${level}`, node.content ?? [], nextKey, list))
83
84
  return
84
85
  }
@@ -407,7 +408,7 @@ function parseObject(block: PortableTextObject): JSONContent {
407
408
  if (heading) {
408
409
  return {
409
410
  type: 'heading',
410
- attrs: { level: Number(heading[1]) },
411
+ attrs: { level: bodyHeadingLevel(Number(heading[1])) },
411
412
  content: parseInline(block),
412
413
  }
413
414
  }
@@ -4,7 +4,7 @@
4
4
  // no EditorView, no DOM, fully testable in vitest (Node env).
5
5
  //
6
6
  // Supported nodes (StarterKit set + TweetEmbed + tables):
7
- // block: doc, paragraph, heading (h1–h6), bulletList, orderedList,
7
+ // block: doc, paragraph, heading (h2–h4; h1 → h2, h5/h6 → h4), bulletList, orderedList,
8
8
  // listItem, blockquote, codeBlock, image, tweetEmbed, hardBreak,
9
9
  // table (tableRow / tableHeader / tableCell)
10
10
  // inline marks: bold, italic, strike, code, link
@@ -30,6 +30,7 @@
30
30
 
31
31
  import type { JSONContent } from '@tiptap/core'
32
32
  import { normalizeBoundaryMarks } from './boundary-marks.js'
33
+ import { bodyHeadingLevel } from './heading-level.js'
33
34
 
34
35
  // ─────────────────────────────────────────────────────────────────────────────
35
36
  // docToMarkdown — JSONContent → string
@@ -52,7 +53,7 @@ function serializeBlock(node: JSONContent): string | null {
52
53
  return text
53
54
  }
54
55
  case 'heading': {
55
- const level = (node.attrs?.level as number) ?? 2
56
+ const level = bodyHeadingLevel((node.attrs?.level as number) ?? 2)
56
57
  const prefix = '#'.repeat(level)
57
58
  const text = serializeInlineContent(node.content ?? [])
58
59
  return `${prefix} ${text}`
@@ -389,7 +390,7 @@ function parseBlocks(md: string): JSONContent[] {
389
390
  // Heading
390
391
  const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line)
391
392
  if (headingMatch) {
392
- const level = headingMatch[1].length
393
+ const level = bodyHeadingLevel(headingMatch[1].length)
393
394
  const text = headingMatch[2].trim()
394
395
  nodes.push({ type: 'heading', attrs: { level }, content: parseInline(text) })
395
396
  i++