@growth-labs/cms 0.8.19 → 0.8.23

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 (182) hide show
  1. package/dist/cli/foundry-emit.d.ts +14 -0
  2. package/dist/cli/foundry-emit.d.ts.map +1 -0
  3. package/dist/cli/foundry-emit.js +69 -0
  4. package/dist/cli/foundry-emit.js.map +1 -0
  5. package/dist/engine/fronts-publish-intent.d.ts +1 -1
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  7. package/dist/engine/fronts-publish-intent.js +12 -1
  8. package/dist/engine/fronts-publish-intent.js.map +1 -1
  9. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  10. package/dist/engine/publisher.d.ts +16 -1
  11. package/dist/engine/publisher.d.ts.map +1 -1
  12. package/dist/engine/publisher.js +55 -4
  13. package/dist/engine/publisher.js.map +1 -1
  14. package/dist/engine/tags.d.ts.map +1 -1
  15. package/dist/engine/tags.js +18 -1
  16. package/dist/engine/tags.js.map +1 -1
  17. package/dist/engine/topics.d.ts.map +1 -1
  18. package/dist/engine/topics.js +4 -1
  19. package/dist/engine/topics.js.map +1 -1
  20. package/dist/engine/video-source.d.ts +21 -0
  21. package/dist/engine/video-source.d.ts.map +1 -1
  22. package/dist/engine/video-source.js +40 -0
  23. package/dist/engine/video-source.js.map +1 -1
  24. package/dist/foundry-emit.d.ts +146 -0
  25. package/dist/foundry-emit.d.ts.map +1 -0
  26. package/dist/foundry-emit.js +189 -0
  27. package/dist/foundry-emit.js.map +1 -0
  28. package/dist/integration/index.d.ts.map +1 -1
  29. package/dist/integration/index.js +9 -0
  30. package/dist/integration/index.js.map +1 -1
  31. package/dist/integration/options.d.ts +24 -0
  32. package/dist/integration/options.d.ts.map +1 -1
  33. package/dist/integration/options.js +22 -0
  34. package/dist/integration/options.js.map +1 -1
  35. package/dist/lib/publish-timezone.d.ts +38 -0
  36. package/dist/lib/publish-timezone.d.ts.map +1 -0
  37. package/dist/lib/publish-timezone.js +135 -0
  38. package/dist/lib/publish-timezone.js.map +1 -0
  39. package/dist/providers/null.d.ts.map +1 -1
  40. package/dist/providers/null.js +6 -0
  41. package/dist/providers/null.js.map +1 -1
  42. package/dist/providers/types.d.ts +56 -0
  43. package/dist/providers/types.d.ts.map +1 -1
  44. package/dist/providers/types.js.map +1 -1
  45. package/dist/routes/ai.d.ts.map +1 -1
  46. package/dist/routes/ai.js +21 -13
  47. package/dist/routes/ai.js.map +1 -1
  48. package/dist/routes/analytics.d.ts +1 -0
  49. package/dist/routes/analytics.d.ts.map +1 -1
  50. package/dist/routes/analytics.js +20 -0
  51. package/dist/routes/analytics.js.map +1 -1
  52. package/dist/routes/content.d.ts +27 -0
  53. package/dist/routes/content.d.ts.map +1 -1
  54. package/dist/routes/content.js +60 -114
  55. package/dist/routes/content.js.map +1 -1
  56. package/dist/routes/foundry-publish.d.ts +50 -0
  57. package/dist/routes/foundry-publish.d.ts.map +1 -1
  58. package/dist/routes/foundry-publish.js +540 -27
  59. package/dist/routes/foundry-publish.js.map +1 -1
  60. package/dist/routes/machine-context.d.ts +6 -0
  61. package/dist/routes/machine-context.d.ts.map +1 -0
  62. package/dist/routes/machine-context.js +20 -0
  63. package/dist/routes/machine-context.js.map +1 -0
  64. package/dist/routes/surveys.d.ts +14 -1
  65. package/dist/routes/surveys.d.ts.map +1 -1
  66. package/dist/routes/surveys.js +515 -10
  67. package/dist/routes/surveys.js.map +1 -1
  68. package/dist/schema/insights-ingest.d.ts +8 -8
  69. package/dist/schema/migrations.d.ts.map +1 -1
  70. package/dist/schema/migrations.js +15 -0
  71. package/dist/schema/migrations.js.map +1 -1
  72. package/dist/schema/tables.d.ts +1 -1
  73. package/dist/schema/tables.d.ts.map +1 -1
  74. package/dist/schema/tables.js +2 -0
  75. package/dist/schema/tables.js.map +1 -1
  76. package/dist/surveys/access.d.ts +1 -0
  77. package/dist/surveys/access.d.ts.map +1 -1
  78. package/dist/surveys/access.js +13 -4
  79. package/dist/surveys/access.js.map +1 -1
  80. package/dist/surveys/auto-label.d.ts +100 -0
  81. package/dist/surveys/auto-label.d.ts.map +1 -0
  82. package/dist/surveys/auto-label.js +325 -0
  83. package/dist/surveys/auto-label.js.map +1 -0
  84. package/dist/surveys/schema.d.ts +38 -12
  85. package/dist/surveys/schema.d.ts.map +1 -1
  86. package/dist/surveys/schema.js +18 -1
  87. package/dist/surveys/schema.js.map +1 -1
  88. package/dist/ui/api/_content-config.d.ts.map +1 -1
  89. package/dist/ui/api/_content-config.js +4 -0
  90. package/dist/ui/api/_content-config.js.map +1 -1
  91. package/dist/ui/api/analytics.d.ts.map +1 -1
  92. package/dist/ui/api/analytics.js +5 -2
  93. package/dist/ui/api/analytics.js.map +1 -1
  94. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  95. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  96. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  97. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  99. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  100. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  101. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  102. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  103. package/dist/ui/api/surveys/[surveyId].js +28 -1
  104. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  105. package/dist/ui/commands.d.ts.map +1 -1
  106. package/dist/ui/commands.js +2 -0
  107. package/dist/ui/commands.js.map +1 -1
  108. package/dist/ui/hash-router.d.ts +1 -1
  109. package/dist/ui/hash-router.d.ts.map +1 -1
  110. package/dist/ui/hash-router.js +1 -0
  111. package/dist/ui/hash-router.js.map +1 -1
  112. package/dist/ui/inspector/PublishTab.d.ts +7 -1
  113. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  114. package/dist/ui/inspector/PublishTab.js +24 -26
  115. package/dist/ui/inspector/PublishTab.js.map +1 -1
  116. package/dist/ui/nav.d.ts.map +1 -1
  117. package/dist/ui/nav.js +1 -0
  118. package/dist/ui/nav.js.map +1 -1
  119. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  120. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  122. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  123. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  124. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  125. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  126. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  127. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  128. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  129. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  130. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  131. package/dist/ui/screens/analytics-data.d.ts +14 -0
  132. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  133. package/dist/ui/screens/analytics-data.js +16 -0
  134. package/dist/ui/screens/analytics-data.js.map +1 -1
  135. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  136. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  137. package/dist/ui/screens/podcast-listening-data.js +98 -0
  138. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  139. package/dist/ui/screens/registry.d.ts.map +1 -1
  140. package/dist/ui/screens/registry.js +2 -0
  141. package/dist/ui/screens/registry.js.map +1 -1
  142. package/docs/foundry-publish.md +208 -0
  143. package/migrations/0033_survey_answer_labels.sql +15 -0
  144. package/package.json +9 -3
  145. package/src/cli/foundry-emit.ts +85 -0
  146. package/src/engine/fronts-publish-intent.ts +23 -1
  147. package/src/engine/publisher.ts +80 -4
  148. package/src/engine/tags.ts +21 -1
  149. package/src/engine/topics.ts +4 -1
  150. package/src/engine/video-source.ts +44 -0
  151. package/src/foundry-emit.ts +255 -0
  152. package/src/integration/index.ts +9 -0
  153. package/src/integration/options.ts +23 -0
  154. package/src/lib/publish-timezone.ts +154 -0
  155. package/src/providers/null.ts +6 -0
  156. package/src/providers/types.ts +52 -0
  157. package/src/routes/ai.ts +29 -17
  158. package/src/routes/analytics.ts +21 -0
  159. package/src/routes/content.ts +86 -131
  160. package/src/routes/foundry-publish.ts +746 -29
  161. package/src/routes/machine-context.ts +24 -0
  162. package/src/routes/surveys.ts +587 -12
  163. package/src/schema/migrations.ts +15 -0
  164. package/src/schema/tables.ts +2 -0
  165. package/src/surveys/access.ts +14 -4
  166. package/src/surveys/auto-label.ts +420 -0
  167. package/src/surveys/schema.ts +25 -1
  168. package/src/ui/api/_content-config.ts +6 -0
  169. package/src/ui/api/analytics.ts +5 -2
  170. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  171. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  172. package/src/ui/api/surveys/[surveyId].ts +37 -3
  173. package/src/ui/commands.ts +2 -0
  174. package/src/ui/hash-router.ts +1 -0
  175. package/src/ui/inspector/PublishTab.tsx +33 -25
  176. package/src/ui/nav.ts +1 -0
  177. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  178. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  179. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  180. package/src/ui/screens/analytics-data.ts +18 -0
  181. package/src/ui/screens/podcast-listening-data.ts +114 -0
  182. package/src/ui/screens/registry.tsx +2 -0
@@ -30,6 +30,7 @@ import {
30
30
  validateDeclaredTagList,
31
31
  validateStoredTaxonomyRows,
32
32
  } from './taxonomy.js'
33
+ import { VideoSourceNotIngestableError, videoSourceUrlIngestableOverHttps } from './video-source.js'
33
34
 
34
35
  export {
35
36
  MAX_ITEM_TAG_COUNT,
@@ -625,6 +626,20 @@ export async function createContent(
625
626
  db: D1Database,
626
627
  input: CreateContentInput,
627
628
  ): Promise<{ id: string; slug: string }> {
629
+ // Refuse before the provider probe or any row write: an http_url source
630
+ // with a query, fragment, userinfo or non-https scheme can never pass
631
+ // Foundry's media probe, so storing it strands a scheduled video (the
632
+ // HomeFronts 2026-10-01T18:05Z incident). Dropbox links legitimately
633
+ // carry `?dl=1` and stay out of this check.
634
+ if (
635
+ input.type === 'video' &&
636
+ input.content.processingSourceUrl !== undefined &&
637
+ input.content.processingSourceUrl !== null &&
638
+ (input.content.processingSourceKind ?? 'http_url') === 'http_url' &&
639
+ !videoSourceUrlIngestableOverHttps(input.content.processingSourceUrl)
640
+ ) {
641
+ throw new VideoSourceNotIngestableError()
642
+ }
628
643
  const resolvedMediaDuration =
629
644
  input.type === 'video'
630
645
  ? await resolveMediaDuration(
@@ -775,6 +790,21 @@ export async function getContentItem(
775
790
  .first<ContentItemRecord>()
776
791
  }
777
792
 
793
+ /**
794
+ * A human-initiated canonical change written outside updateContentItem (an
795
+ * editor-triggered AI write-back to a field updateContentItem does not carry):
796
+ * advances canonical_version exactly as updateContentItem's own UPDATE does.
797
+ * canonical_version is how the Foundry machine route knows the working copy is
798
+ * no longer only what its last PUT wrote (M3-18 resync guard).
799
+ */
800
+ export function canonicalEditStatement(db: D1Database, contentId: string) {
801
+ return db
802
+ .prepare(
803
+ 'UPDATE content_items SET canonical_version = canonical_version + 1, updated_at = unixepoch() WHERE id = ?',
804
+ )
805
+ .bind(contentId)
806
+ }
807
+
778
808
  export async function updateContentItem(
779
809
  db: D1Database,
780
810
  id: string,
@@ -823,6 +853,16 @@ export async function updateContentItem(
823
853
  const sourceKind = hasOwn(videoContent, 'processingSourceKind')
824
854
  ? (videoContent.processingSourceKind ?? null)
825
855
  : current.processing_source_kind
856
+ // Same refusal as createContent, against the merged (input-or-stored)
857
+ // source: a row that already holds a query-bearing link is corrected
858
+ // at the next save, while the editor is still there to re-capture it.
859
+ if (
860
+ sourceUrl !== null &&
861
+ (sourceKind ?? 'http_url') === 'http_url' &&
862
+ !videoSourceUrlIngestableOverHttps(sourceUrl)
863
+ ) {
864
+ throw new VideoSourceNotIngestableError()
865
+ }
826
866
  const sourceChanged =
827
867
  sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
828
868
  const durationProvided = hasOwn(videoContent, 'durationSeconds')
@@ -997,8 +1037,8 @@ export async function updateContentItem(
997
1037
  : existing.metadata_json
998
1038
 
999
1039
  if (options.requiredStatus) {
1000
- if (input.tags !== undefined || input.relations !== undefined) {
1001
- throw new TypeError('Status-constrained updates do not support tags or relations')
1040
+ if (input.relations !== undefined) {
1041
+ throw new TypeError('Status-constrained updates do not support relations')
1002
1042
  }
1003
1043
  if (!isBodyBackedContentType(existing.type)) {
1004
1044
  throw new TypeError('Status-constrained updates require body-backed content')
@@ -1131,6 +1171,31 @@ export async function updateContentItem(
1131
1171
  )
1132
1172
  }
1133
1173
 
1174
+ if (tagPairs) {
1175
+ // Tag links join the same transaction under the same status predicate,
1176
+ // so a refused (non-matching status) update leaves the links untouched.
1177
+ // Tag dictionary rows are shared and idempotent, so they may be ensured
1178
+ // up front even when the guarded write below is refused.
1179
+ const tagRows = tagPairs.length > 0 ? await ensureTags(db, tagPairs) : []
1180
+ const statusGuard = `EXISTS (SELECT 1 FROM content_items
1181
+ WHERE id = ? AND status = ? AND deleted_at IS NULL)`
1182
+ statements.push(
1183
+ db
1184
+ .prepare(`DELETE FROM content_tag_links WHERE content_id = ? AND ${statusGuard}`)
1185
+ .bind(id, id, options.requiredStatus),
1186
+ )
1187
+ for (const tag of tagRows) {
1188
+ statements.push(
1189
+ db
1190
+ .prepare(
1191
+ `INSERT INTO content_tag_links (content_id, tag_id, label)
1192
+ SELECT ?, ?, ? WHERE ${statusGuard}`,
1193
+ )
1194
+ .bind(id, tag.id, tag.label, id, options.requiredStatus),
1195
+ )
1196
+ }
1197
+ }
1198
+
1134
1199
  const statusResultIndex = statements.length
1135
1200
  statements.push(
1136
1201
  db
@@ -1677,14 +1742,25 @@ export async function claimScheduledForPublish(
1677
1742
  return changes > 0
1678
1743
  }
1679
1744
 
1680
- export async function unpublishContent(db: D1Database, id: string): Promise<void> {
1745
+ export async function unpublishContent(
1746
+ db: D1Database,
1747
+ id: string,
1748
+ options: {
1749
+ /**
1750
+ * A person pulled the item: advance canonical_version in the same UPDATE,
1751
+ * so a machine publisher can never put it back without a new write of its
1752
+ * own (the Foundry machine route's human-edit guard, M3-18).
1753
+ */
1754
+ canonicalEdit?: boolean
1755
+ } = {},
1756
+ ): Promise<void> {
1681
1757
  await db
1682
1758
  .prepare(
1683
1759
  `UPDATE content_items
1684
1760
  SET status = 'draft',
1685
1761
  published_at = NULL,
1686
1762
  publish_at = NULL,
1687
- updated_at = unixepoch()
1763
+ updated_at = unixepoch()${options.canonicalEdit ? ', canonical_version = canonical_version + 1' : ''}
1688
1764
  WHERE id = ?`,
1689
1765
  )
1690
1766
  .bind(id)
@@ -106,12 +106,32 @@ export async function renameTag(db: D1Database, tagId: string, label: string): P
106
106
  .prepare('UPDATE content_tags SET label = ?, slug = ? WHERE id = ?')
107
107
  .bind(tag.label, tag.slug, tagId),
108
108
  db.prepare('UPDATE content_tag_links SET label = ? WHERE tag_id = ?').bind(tag.label, tagId),
109
+ // A rename is a human-initiated canonical change to every item carrying
110
+ // the tag, exactly as topic rename is (engine/topics.ts).
111
+ bumpTaggedItems(db, tagId),
109
112
  ])
110
113
  return Boolean(res.meta && (res.meta as { changes?: number }).changes)
111
114
  }
112
115
 
113
116
  export async function deleteTag(db: D1Database, tagId: string): Promise<boolean> {
114
117
  // content_tag_links has ON DELETE CASCADE on tag_id (0001), so links go too.
115
- const res = await db.prepare('DELETE FROM content_tags WHERE id = ?').bind(tagId).run()
118
+ // The tagged items are bumped first, in the same batch, while the links that
119
+ // name them still exist.
120
+ const [, res] = await db.batch([
121
+ bumpTaggedItems(db, tagId),
122
+ db.prepare('DELETE FROM content_tags WHERE id = ?').bind(tagId),
123
+ ])
116
124
  return Boolean(res.meta && (res.meta as { changes?: number }).changes)
117
125
  }
126
+
127
+ /** canonical_version + 1 for every live item linked to the tag. */
128
+ function bumpTaggedItems(db: D1Database, tagId: string) {
129
+ return db
130
+ .prepare(
131
+ `UPDATE content_items
132
+ SET canonical_version = canonical_version + 1, updated_at = unixepoch()
133
+ WHERE deleted_at IS NULL
134
+ AND id IN (SELECT content_id FROM content_tag_links WHERE tag_id = ?)`,
135
+ )
136
+ .bind(tagId)
137
+ }
@@ -133,7 +133,10 @@ export async function renameTopic(
133
133
  const res = await db
134
134
  .prepare(
135
135
  `UPDATE content_items
136
- SET primary_topic = ?, updated_at = unixepoch()
136
+ SET primary_topic = ?, updated_at = unixepoch(),
137
+ -- A rename is a human-initiated canonical change to every item it
138
+ -- touches, exactly as an editor's per-item topic change is.
139
+ canonical_version = canonical_version + 1
137
140
  WHERE deleted_at IS NULL AND primary_topic = ?`,
138
141
  )
139
142
  .bind(nextSlug, currentSlug)
@@ -46,3 +46,47 @@ export async function frontsVideoSourceGeneration(
46
46
  export function videoSourceIngestableOverHttps(kind: string | null | undefined): boolean {
47
47
  return kind === 'http_url' || kind === 'dropbox'
48
48
  }
49
+
50
+ /**
51
+ * The URL shape Foundry's ingest accepts (capabilityplane/ingest.go
52
+ * `validHTTPSIngestURL`): https, a host, and no userinfo, query or
53
+ * fragment. A URL outside this shape is refused at the Go media probe with
54
+ * "artifact ingest probe source URL is invalid" — the failure that blocked
55
+ * a scheduled HomeFronts video on 2026-10-01T18:05Z. The IP-range checks
56
+ * (`PublicHTTPSIngestHost`) stay Foundry's: the CMS only refuses what no
57
+ * host would accept.
58
+ */
59
+ export function videoSourceUrlIngestableOverHttps(rawUrl: string | null | undefined): boolean {
60
+ if (!rawUrl) return false
61
+ let url: URL
62
+ try {
63
+ url = new URL(rawUrl)
64
+ } catch {
65
+ return false
66
+ }
67
+ return (
68
+ url.protocol === 'https:' &&
69
+ url.hostname !== '' &&
70
+ !url.username &&
71
+ !url.password &&
72
+ url.search === '' &&
73
+ url.hash === ''
74
+ )
75
+ }
76
+
77
+ /**
78
+ * A video source whose URL `videoSourceUrlIngestableOverHttps` refuses (a
79
+ * query string, fragment, userinfo or non-https scheme). Thrown by the
80
+ * publisher's video write so the save fails before a row can strand a
81
+ * scheduled video at the Go media probe. The message is the whole
82
+ * editor-facing error and deliberately echoes no part of the URL — a share
83
+ * link's path or query can carry its token.
84
+ */
85
+ export class VideoSourceNotIngestableError extends Error {
86
+ constructor() {
87
+ super(
88
+ 'Video source URL must be a direct HTTPS link with no query string or fragment — Foundry refuses to ingest it. A Fulcrum Drive share link must be pasted in its direct /download form.',
89
+ )
90
+ this.name = 'VideoSourceNotIngestableError'
91
+ }
92
+ }
@@ -0,0 +1,255 @@
1
+ // @growth-labs/cms/foundry-emit — the one v2 schema for the Foundry machine
2
+ // article route, shared by the route (routes/foundry-publish.ts) and by every
3
+ // producer that emits into it, plus a pure mapper from the archive-import item
4
+ // shape (ContentArchiveItem, as the channel repos' compiler.mjs builds it) to a
5
+ // v2 body.
6
+ //
7
+ // This module must stay importable in pure Node: zod, the pure slug helpers and
8
+ // type-only imports. Nothing here touches D1, the network or `cloudflare:*`.
9
+
10
+ import { sha256Hex } from '@growth-labs/foundry-signing'
11
+ import { z } from 'zod'
12
+ import type { ContentArchiveItem } from './engine/publication.js'
13
+ import { slugify } from './engine/slug.js'
14
+
15
+ export const FOUNDRY_ARTICLE_SCHEMA_VERSION = 2 as const
16
+
17
+ /** The machine route's own source namespace; an adoption never targets it. */
18
+ export const FOUNDRY_SOURCE_PREFIX = 'foundry:'
19
+
20
+ /** Upper bound the publication kernel accepts for a revision's slug. */
21
+ export const FOUNDRY_SLUG_MAX_LENGTH = 512
22
+
23
+ /**
24
+ * The engine's slug rule, applied verbatim: a v2 slug is accepted only when it
25
+ * is already canonical (`slugify(slug) === slug`), because the route never
26
+ * derives, rewrites or suffixes a v2 slug.
27
+ */
28
+ export function isCanonicalSlug(value: string): boolean {
29
+ return value.length >= 3 && value.length <= FOUNDRY_SLUG_MAX_LENGTH && slugify(value) === value
30
+ }
31
+
32
+ const HERO_IMAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9._\-/]*$/
33
+
34
+ export const FoundryArticleV2Schema = z
35
+ .object({
36
+ schemaVersion: z.literal(FOUNDRY_ARTICLE_SCHEMA_VERSION),
37
+ slug: z.string().refine(isCanonicalSlug, {
38
+ message: 'slug must already be canonical: lowercase a-z, 0-9 and single hyphens, 3-512 chars',
39
+ }),
40
+ title: z.string().trim().min(3),
41
+ body_html: z.string().min(1),
42
+ hero_image_key: z
43
+ .string()
44
+ .min(1)
45
+ .max(1024)
46
+ .refine((key) => HERO_IMAGE_KEY.test(key) && !key.split('/').includes('..'), {
47
+ message: 'hero_image_key must be a relative object key',
48
+ })
49
+ .optional(),
50
+ hero_image_url: z
51
+ .string()
52
+ .url()
53
+ .refine((url) => url.startsWith('https://'), { message: 'hero_image_url must be https' })
54
+ .optional(),
55
+ publishAt: z.string().datetime({ offset: true }).optional(),
56
+ author: z.string().trim().min(1).max(200).optional(),
57
+ desk: z.string().trim().min(1).max(100).optional(),
58
+ tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
59
+ description: z.string().trim().min(1).optional(),
60
+ visibility: z.enum(['free', 'premium']).optional(),
61
+ adoptSourceId: z
62
+ .string()
63
+ .min(1)
64
+ .max(256)
65
+ .refine((value) => value === value.trim() && !value.startsWith(FOUNDRY_SOURCE_PREFIX), {
66
+ message: 'adoptSourceId must be a trimmed bridge source id, never a foundry: id',
67
+ })
68
+ .optional(),
69
+ })
70
+ .strict()
71
+ .refine((body) => !(body.hero_image_key && body.hero_image_url), {
72
+ message: 'send hero_image_key or hero_image_url, not both',
73
+ path: ['hero_image_key'],
74
+ })
75
+
76
+ export type FoundryArticleV2 = z.output<typeof FoundryArticleV2Schema>
77
+
78
+ export class FoundryEmitError extends Error {
79
+ readonly code: string
80
+
81
+ constructor(code: string, message: string) {
82
+ super(message)
83
+ this.name = 'FoundryEmitError'
84
+ this.code = code
85
+ }
86
+ }
87
+
88
+ export interface FoundryEmitOptions {
89
+ /**
90
+ * Overrides the publication time. Omitted: the item's `publishedAt` (unix
91
+ * seconds) is used when present. `null`: no publishAt, so the route writes a
92
+ * draft only.
93
+ */
94
+ publishAt?: string | null
95
+ }
96
+
97
+ function present(value: string | null | undefined): value is string {
98
+ return typeof value === 'string' && value.trim().length > 0
99
+ }
100
+
101
+ /**
102
+ * Map one archive-import item to a v2 route body. Pure: the same item always
103
+ * yields the same body, and the result is parsed by the route's own schema, so
104
+ * a body this function returns is one the route accepts.
105
+ */
106
+ export function toFoundryArticleV2(
107
+ item: ContentArchiveItem,
108
+ options: FoundryEmitOptions = {},
109
+ ): FoundryArticleV2 {
110
+ if (item.type !== undefined && item.type !== 'article') {
111
+ throw new FoundryEmitError('unsupported_type', `only articles are emitted, got ${item.type}`)
112
+ }
113
+ if (!present(item.slug)) throw new FoundryEmitError('slug_missing', 'archive item has no slug')
114
+ if (!present(item.title)) throw new FoundryEmitError('title_missing', 'archive item has no title')
115
+ if (!present(item.bodyHtml)) {
116
+ throw new FoundryEmitError('body_html_missing', 'archive item has no rendered bodyHtml')
117
+ }
118
+
119
+ let publishAt: string | undefined
120
+ if (options.publishAt !== undefined) {
121
+ publishAt = options.publishAt ?? undefined
122
+ } else if (typeof item.publishedAt === 'number' && Number.isFinite(item.publishedAt)) {
123
+ publishAt = new Date(item.publishedAt * 1_000).toISOString()
124
+ }
125
+
126
+ const hero = present(item.heroImageId) ? item.heroImageId.trim() : null
127
+ const author = present(item.byline) ? item.byline : present(item.authorId) ? item.authorId : null
128
+ const description = present(item.description)
129
+ ? item.description
130
+ : present(item.excerpt)
131
+ ? item.excerpt
132
+ : null
133
+
134
+ const body: Record<string, unknown> = {
135
+ schemaVersion: FOUNDRY_ARTICLE_SCHEMA_VERSION,
136
+ slug: item.slug,
137
+ title: item.title,
138
+ body_html: item.bodyHtml,
139
+ ...(hero
140
+ ? /^https?:\/\//i.test(hero)
141
+ ? { hero_image_url: hero }
142
+ : { hero_image_key: hero }
143
+ : {}),
144
+ ...(publishAt ? { publishAt } : {}),
145
+ ...(author ? { author } : {}),
146
+ ...(present(item.primaryCategory) ? { desk: item.primaryCategory } : {}),
147
+ ...(item.tags && item.tags.length > 0 ? { tags: [...item.tags] } : {}),
148
+ ...(description ? { description } : {}),
149
+ ...(item.visibility ? { visibility: item.visibility } : {}),
150
+ ...(present(item.sourceId) ? { adoptSourceId: item.sourceId } : {}),
151
+ }
152
+ return FoundryArticleV2Schema.parse(body)
153
+ }
154
+
155
+ // --- content-emit.json: the whole archive as the exact PUT bodies foundryd sends ---
156
+
157
+ export const CONTENT_EMIT_SCHEMA_VERSION = 1 as const
158
+
159
+ /** An item the producer retires (frontmatter `status: archived`): no request is sent. */
160
+ export interface ContentEmitRetireItem {
161
+ path: string
162
+ videoId: string | null
163
+ retire: true
164
+ }
165
+
166
+ /**
167
+ * An item to PUT. `request` is the exact PUT body as a JSON STRING, never a
168
+ * nested object, so re-indenting the envelope cannot change its bytes;
169
+ * `requestSha256` is the lowercase hex SHA-256 of its UTF-8 bytes, which is the
170
+ * `request_sha256` the route stores and echoes for that PUT.
171
+ */
172
+ export interface ContentEmitPutItem {
173
+ path: string
174
+ videoId: string | null
175
+ retire: false
176
+ requestSha256: string
177
+ request: string
178
+ }
179
+
180
+ export type ContentEmitItem = ContentEmitRetireItem | ContentEmitPutItem
181
+
182
+ export interface ContentEmit {
183
+ schemaVersion: typeof CONTENT_EMIT_SCHEMA_VERSION
184
+ app: string
185
+ sourceSha: string
186
+ items: ContentEmitItem[]
187
+ }
188
+
189
+ const APP_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
190
+ const SOURCE_SHA = /^[0-9a-f]{7,64}$/
191
+
192
+ /** Lowercase hex SHA-256 of a string's UTF-8 bytes (the shared implementation). */
193
+ export { sha256Hex }
194
+
195
+ function archivePath(item: ContentArchiveItem, index: number): string {
196
+ const sourceId = item.sourceId
197
+ const separator = typeof sourceId === 'string' ? sourceId.indexOf(':') : -1
198
+ if (!sourceId || separator < 1 || separator === sourceId.length - 1) {
199
+ throw new FoundryEmitError(
200
+ 'source_id_missing',
201
+ `archive item ${index} has no "<ns>:<relpath>" sourceId`,
202
+ )
203
+ }
204
+ return sourceId.slice(separator + 1)
205
+ }
206
+
207
+ /**
208
+ * Build content-emit.json for a WHOLE archive. Every item is either retired
209
+ * (`metadata.originalStatus === 'archived'`, the compiler's carry of frontmatter
210
+ * `status: archived`) or carries its exact v2 PUT body. Any item that cannot be
211
+ * emitted fails the whole build: a partial emit would silently drop articles.
212
+ */
213
+ export async function buildContentEmit(
214
+ archive: { items?: ContentArchiveItem[] },
215
+ options: { app: string; sourceSha: string },
216
+ ): Promise<ContentEmit> {
217
+ if (!APP_ID.test(options.app)) {
218
+ throw new FoundryEmitError('app_invalid', 'app must be a lowercase id (a-z, 0-9, -)')
219
+ }
220
+ if (!SOURCE_SHA.test(options.sourceSha)) {
221
+ throw new FoundryEmitError('source_sha_invalid', 'sourceSha must be a lowercase hex commit')
222
+ }
223
+ if (!Array.isArray(archive.items)) {
224
+ throw new FoundryEmitError('archive_invalid', 'archive has no items array')
225
+ }
226
+ const seen = new Set<string>()
227
+ const items: ContentEmitItem[] = []
228
+ for (const [index, item] of archive.items.entries()) {
229
+ const path = archivePath(item, index)
230
+ if (seen.has(path)) throw new FoundryEmitError('path_duplicate', `duplicate path ${path}`)
231
+ seen.add(path)
232
+ const videoId =
233
+ typeof item.metadata?.videoId === 'string' && item.metadata.videoId
234
+ ? item.metadata.videoId
235
+ : null
236
+ if (item.metadata?.originalStatus === 'archived') {
237
+ items.push({ path, videoId, retire: true })
238
+ continue
239
+ }
240
+ let request: string
241
+ try {
242
+ request = JSON.stringify(toFoundryArticleV2(item))
243
+ } catch (error) {
244
+ const detail = error instanceof Error ? error.message : String(error)
245
+ throw new FoundryEmitError('item_invalid', `${path}: ${detail}`)
246
+ }
247
+ items.push({ path, videoId, retire: false, requestSha256: await sha256Hex(request), request })
248
+ }
249
+ return {
250
+ schemaVersion: CONTENT_EMIT_SCHEMA_VERSION,
251
+ app: options.app,
252
+ sourceSha: options.sourceSha,
253
+ items,
254
+ }
255
+ }
@@ -33,6 +33,10 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
33
33
  pattern: '/api/cms/foundry/articles/[external_id]/unpublish',
34
34
  entrypoint: resolveEntrypoint('../ui/api/foundry/articles/[external_id]/unpublish'),
35
35
  })
36
+ injectRoute({
37
+ pattern: '/api/cms/foundry/articles/[external_id]/publish',
38
+ entrypoint: resolveEntrypoint('../ui/api/foundry/articles/[external_id]/publish'),
39
+ })
36
40
  // the SPA shell index (SSR, prerender=false)
37
41
  injectRoute({ pattern: base, entrypoint: resolveAstro('../ui/pages/admin.astro') })
38
42
  // the /admin/api/* JSON routes
@@ -270,6 +274,11 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
270
274
  pattern: `${base}/api/surveys/[surveyId]/responses.csv`,
271
275
  entrypoint: resolveEntrypoint('../ui/api/surveys/[surveyId]/responses.csv'),
272
276
  })
277
+ // Owner-only classification of a written answer (technical/general).
278
+ injectRoute({
279
+ pattern: `${base}/api/surveys/[surveyId]/answers/label`,
280
+ entrypoint: resolveEntrypoint('../ui/api/surveys/[surveyId]/answers/label'),
281
+ })
273
282
  // Calendar
274
283
  injectRoute({
275
284
  pattern: `${base}/api/calendar`,
@@ -1,3 +1,4 @@
1
+ import { isCanonicalHost } from '@growth-labs/foundry-signing'
1
2
  import { z } from 'zod'
2
3
  import { SurveyIdSchema } from '../surveys/schema.js'
3
4
 
@@ -124,6 +125,17 @@ const surveyResultsSchema = z
124
125
  })
125
126
  .strict()
126
127
 
128
+ const surveysOptionsSchema = z
129
+ .object({
130
+ // Automatic technical/general labeling of new written survey answers
131
+ // through the host's LlmDispatch (2026-10-02, packet 08), scheduled off
132
+ // the survey results request via waitUntil. Off by default: a site
133
+ // opts in, and only a site that wired the LlmDispatch seam pays for
134
+ // the background dispatches.
135
+ autoLabel: z.boolean().default(false),
136
+ })
137
+ .default({})
138
+
127
139
  export const cmsIntegrationOptionsSchema = z
128
140
  .object({
129
141
  workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
@@ -143,6 +155,17 @@ export const cmsIntegrationOptionsSchema = z
143
155
  theme: themeSchema,
144
156
  socialSharing: socialSharingSchema,
145
157
  surveyResults: surveyResultsSchema.optional(),
158
+ surveys: surveysOptionsSchema,
159
+ // This publication's canonical host for the Foundry machine route
160
+ // (/api/cms/foundry/*), e.g. `warfronts.pub`. Declared, the route verifies
161
+ // only the host-bound foundry-cms-v2 signature on every request and never
162
+ // the legacy form, whatever the per-request hooks set.
163
+ foundryCanonicalHost: z
164
+ .string()
165
+ .refine(isCanonicalHost, {
166
+ message: 'foundryCanonicalHost must be a lowercase host with no scheme or port',
167
+ })
168
+ .optional(),
146
169
  })
147
170
  .transform((o) => ({ ...o, activeWorkspaceId: o.activeWorkspaceId ?? o.workspaces[0].id }))
148
171