@growth-labs/cms 0.8.20 → 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 (172) 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/providers/null.d.ts.map +1 -1
  36. package/dist/providers/null.js +6 -0
  37. package/dist/providers/null.js.map +1 -1
  38. package/dist/providers/types.d.ts +56 -0
  39. package/dist/providers/types.d.ts.map +1 -1
  40. package/dist/providers/types.js.map +1 -1
  41. package/dist/routes/ai.d.ts.map +1 -1
  42. package/dist/routes/ai.js +21 -13
  43. package/dist/routes/ai.js.map +1 -1
  44. package/dist/routes/analytics.d.ts +1 -0
  45. package/dist/routes/analytics.d.ts.map +1 -1
  46. package/dist/routes/analytics.js +20 -0
  47. package/dist/routes/analytics.js.map +1 -1
  48. package/dist/routes/content.d.ts +27 -0
  49. package/dist/routes/content.d.ts.map +1 -1
  50. package/dist/routes/content.js +58 -12
  51. package/dist/routes/content.js.map +1 -1
  52. package/dist/routes/foundry-publish.d.ts +50 -0
  53. package/dist/routes/foundry-publish.d.ts.map +1 -1
  54. package/dist/routes/foundry-publish.js +540 -27
  55. package/dist/routes/foundry-publish.js.map +1 -1
  56. package/dist/routes/machine-context.d.ts +6 -0
  57. package/dist/routes/machine-context.d.ts.map +1 -0
  58. package/dist/routes/machine-context.js +20 -0
  59. package/dist/routes/machine-context.js.map +1 -0
  60. package/dist/routes/surveys.d.ts +14 -1
  61. package/dist/routes/surveys.d.ts.map +1 -1
  62. package/dist/routes/surveys.js +515 -10
  63. package/dist/routes/surveys.js.map +1 -1
  64. package/dist/schema/insights-ingest.d.ts +8 -8
  65. package/dist/schema/migrations.d.ts.map +1 -1
  66. package/dist/schema/migrations.js +15 -0
  67. package/dist/schema/migrations.js.map +1 -1
  68. package/dist/schema/tables.d.ts +1 -1
  69. package/dist/schema/tables.d.ts.map +1 -1
  70. package/dist/schema/tables.js +2 -0
  71. package/dist/schema/tables.js.map +1 -1
  72. package/dist/surveys/access.d.ts +1 -0
  73. package/dist/surveys/access.d.ts.map +1 -1
  74. package/dist/surveys/access.js +13 -4
  75. package/dist/surveys/access.js.map +1 -1
  76. package/dist/surveys/auto-label.d.ts +100 -0
  77. package/dist/surveys/auto-label.d.ts.map +1 -0
  78. package/dist/surveys/auto-label.js +325 -0
  79. package/dist/surveys/auto-label.js.map +1 -0
  80. package/dist/surveys/schema.d.ts +38 -12
  81. package/dist/surveys/schema.d.ts.map +1 -1
  82. package/dist/surveys/schema.js +18 -1
  83. package/dist/surveys/schema.js.map +1 -1
  84. package/dist/ui/api/_content-config.d.ts.map +1 -1
  85. package/dist/ui/api/_content-config.js +4 -0
  86. package/dist/ui/api/_content-config.js.map +1 -1
  87. package/dist/ui/api/analytics.d.ts.map +1 -1
  88. package/dist/ui/api/analytics.js +5 -2
  89. package/dist/ui/api/analytics.js.map +1 -1
  90. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  91. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  92. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  93. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  94. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  95. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  96. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  97. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  99. package/dist/ui/api/surveys/[surveyId].js +28 -1
  100. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  101. package/dist/ui/commands.d.ts.map +1 -1
  102. package/dist/ui/commands.js +2 -0
  103. package/dist/ui/commands.js.map +1 -1
  104. package/dist/ui/hash-router.d.ts +1 -1
  105. package/dist/ui/hash-router.d.ts.map +1 -1
  106. package/dist/ui/hash-router.js +1 -0
  107. package/dist/ui/hash-router.js.map +1 -1
  108. package/dist/ui/nav.d.ts.map +1 -1
  109. package/dist/ui/nav.js +1 -0
  110. package/dist/ui/nav.js.map +1 -1
  111. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  112. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  113. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  114. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  115. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  116. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  117. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  118. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  119. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  120. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  122. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  123. package/dist/ui/screens/analytics-data.d.ts +14 -0
  124. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  125. package/dist/ui/screens/analytics-data.js +16 -0
  126. package/dist/ui/screens/analytics-data.js.map +1 -1
  127. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  128. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  129. package/dist/ui/screens/podcast-listening-data.js +98 -0
  130. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  131. package/dist/ui/screens/registry.d.ts.map +1 -1
  132. package/dist/ui/screens/registry.js +2 -0
  133. package/dist/ui/screens/registry.js.map +1 -1
  134. package/docs/foundry-publish.md +208 -0
  135. package/migrations/0033_survey_answer_labels.sql +15 -0
  136. package/package.json +9 -3
  137. package/src/cli/foundry-emit.ts +85 -0
  138. package/src/engine/fronts-publish-intent.ts +23 -1
  139. package/src/engine/publisher.ts +80 -4
  140. package/src/engine/tags.ts +21 -1
  141. package/src/engine/topics.ts +4 -1
  142. package/src/engine/video-source.ts +44 -0
  143. package/src/foundry-emit.ts +255 -0
  144. package/src/integration/index.ts +9 -0
  145. package/src/integration/options.ts +23 -0
  146. package/src/providers/null.ts +6 -0
  147. package/src/providers/types.ts +52 -0
  148. package/src/routes/ai.ts +29 -17
  149. package/src/routes/analytics.ts +21 -0
  150. package/src/routes/content.ts +81 -15
  151. package/src/routes/foundry-publish.ts +746 -29
  152. package/src/routes/machine-context.ts +24 -0
  153. package/src/routes/surveys.ts +587 -12
  154. package/src/schema/migrations.ts +15 -0
  155. package/src/schema/tables.ts +2 -0
  156. package/src/surveys/access.ts +14 -4
  157. package/src/surveys/auto-label.ts +420 -0
  158. package/src/surveys/schema.ts +25 -1
  159. package/src/ui/api/_content-config.ts +6 -0
  160. package/src/ui/api/analytics.ts +5 -2
  161. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  162. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  163. package/src/ui/api/surveys/[surveyId].ts +37 -3
  164. package/src/ui/commands.ts +2 -0
  165. package/src/ui/hash-router.ts +1 -0
  166. package/src/ui/nav.ts +1 -0
  167. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  168. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  169. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  170. package/src/ui/screens/analytics-data.ts +18 -0
  171. package/src/ui/screens/podcast-listening-data.ts +114 -0
  172. package/src/ui/screens/registry.tsx +2 -0
@@ -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
 
@@ -69,6 +69,12 @@ export const nullAnalytics: AnalyticsProvider = {
69
69
  },
70
70
  })
71
71
  },
72
+ // Podcast listening analytics is a host opt-in: null (not an empty
73
+ // envelope) so the route 404s and the panel reports "not available"
74
+ // instead of rendering silent zeros for a site that has none.
75
+ async getPodcastListening() {
76
+ return null
77
+ },
72
78
  }
73
79
 
74
80
  export const nullSubscriptions: SubscriptionsProvider = {
@@ -89,6 +89,51 @@ export interface ArticleAnalyticsDetail {
89
89
  funnel?: ArticleFunnelStages
90
90
  }
91
91
 
92
+ export interface PodcastListeningEpisode {
93
+ slug: string
94
+ title?: string
95
+ /** Real press-play attempts (user-initiated audio_play; autoplay excluded). */
96
+ plays: number
97
+ /** Distinct sessions that played this episode. */
98
+ listeners: number
99
+ reached10: number
100
+ reached25: number
101
+ reached50: number
102
+ reached75: number
103
+ reached90: number
104
+ completes: number
105
+ /** completes / plays; 0 when plays is 0. */
106
+ completionRate: number
107
+ /**
108
+ * Total seconds actually listened. Null means NOT MEASURED — no
109
+ * `audio_played` rows exist for this episode in the range — and the UI
110
+ * must render "—", never a silent zero.
111
+ */
112
+ totalListeningSeconds: number | null
113
+ /** totalListeningSeconds / listeners; null when either input is unmeasured. */
114
+ avgListeningSecondsPerListener: number | null
115
+ }
116
+
117
+ export interface PodcastListening {
118
+ episodes: PodcastListeningEpisode[]
119
+ totals: {
120
+ /** Episodes with at least one real play in the range. */
121
+ episodes: number
122
+ plays: number
123
+ /**
124
+ * Distinct sessions across every episode, deduplicated — never the sum
125
+ * of per-episode listeners, which would double-count a session that
126
+ * listened to two episodes.
127
+ */
128
+ listeners: number
129
+ completes: number
130
+ completionRate: number
131
+ totalListeningSeconds: number | null
132
+ }
133
+ /** Echo of the requested window, ISO 8601 instants. */
134
+ range: { from: string; to: string }
135
+ }
136
+
92
137
  export interface AnalyticsProvider {
93
138
  getKpis(range: DateRange): Promise<ProviderResult<Record<string, number | null>>>
94
139
  getReadsTimeSeries(range: DateRange): Promise<ProviderResult<Array<{ t: number; reads: number }>>>
@@ -99,6 +144,13 @@ export interface AnalyticsProvider {
99
144
  range: DateRange,
100
145
  ): Promise<ProviderResult<Array<{ source: string; sessions: number }>>>
101
146
  getArticleDetail(slug: string, range: DateRange): Promise<ProviderResult<ArticleAnalyticsDetail>>
147
+ /**
148
+ * Per-episode podcast listening analytics. Null means this site has no
149
+ * podcast listening analytics at all (the null provider) — the CMS hides
150
+ * the panel. A site that implements it but fails to read its source
151
+ * throws, so the route surfaces a loud error instead of zeros.
152
+ */
153
+ getPodcastListening(input: { from: string; to: string }): Promise<PodcastListening | null>
102
154
  }
103
155
 
104
156
  export interface SubscriptionMember {
package/src/routes/ai.ts CHANGED
@@ -23,7 +23,12 @@ import {
23
23
  } from '../engine/ai-prompts.js'
24
24
  import { applyAiWrite, parseLockedFields } from '../engine/ai-writeback.js'
25
25
  import { buildOgCardSpec } from '../engine/og-render.js'
26
- import { type ContentItemRecord, getContentItem } from '../engine/publisher.js'
26
+ import {
27
+ type ContentItemRecord,
28
+ canonicalEditStatement,
29
+ getContentItem,
30
+ updateContentItem,
31
+ } from '../engine/publisher.js'
27
32
  import type { LlmDispatchResult } from '../providers/types.js'
28
33
  import { canPerformContentAction } from './authz-matrix.js'
29
34
  import type { CmsRouteConfig } from './config.js'
@@ -207,6 +212,8 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
207
212
  if (result.status === 'unavailable') {
208
213
  return llmUnavailableResponse({ reason: result.reason }, { dek: null, written: false })
209
214
  }
215
+ // An editor-triggered dek is a human-initiated canonical change.
216
+ if (result.written) await canonicalEditStatement(ctx.db, idResult.id).run()
210
217
  return json({
211
218
  dek: result.dek,
212
219
  written: result.written,
@@ -322,16 +329,19 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
322
329
 
323
330
  if (writeResult.written) {
324
331
  const now = Math.floor(Date.now() / 1000)
325
- await ctx.db
326
- .prepare(
327
- `UPDATE article_content
332
+ // An editor-triggered write-back is a human-initiated canonical change.
333
+ await ctx.db.batch([
334
+ ctx.db
335
+ .prepare(
336
+ `UPDATE article_content
328
337
  SET ai_takeaways = ?,
329
338
  ai_takeaways_at = ?,
330
339
  ai_takeaways_model = ?
331
340
  WHERE content_id = ?`,
332
- )
333
- .bind(JSON.stringify(takeaways), now, 'llm-dispatch', id)
334
- .run()
341
+ )
342
+ .bind(JSON.stringify(takeaways), now, 'llm-dispatch', id),
343
+ canonicalEditStatement(ctx.db, id),
344
+ ])
335
345
  }
336
346
 
337
347
  return json({
@@ -392,10 +402,9 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
392
402
  })
393
403
 
394
404
  if (writeResult.written) {
395
- await ctx.db
396
- .prepare('UPDATE content_items SET description = ?, updated_at = unixepoch() WHERE id = ?')
397
- .bind(description, id)
398
- .run()
405
+ // Through the editor's own save path, so it is a canonical edit. The
406
+ // existing category is passed through so the save changes nothing else.
407
+ await updateContentItem(ctx.db, id, { description, primaryCategory: item.channel })
399
408
  }
400
409
 
401
410
  return json({
@@ -478,12 +487,15 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
478
487
  })
479
488
 
480
489
  if (writeResult.written) {
481
- await ctx.db
482
- .prepare(
483
- 'UPDATE content_items SET ai_og_image_id = ?, updated_at = unixepoch() WHERE id = ?',
484
- )
485
- .bind(imageKey, id)
486
- .run()
490
+ // An editor-triggered write-back is a human-initiated canonical change.
491
+ await ctx.db.batch([
492
+ ctx.db
493
+ .prepare(
494
+ 'UPDATE content_items SET ai_og_image_id = ?, updated_at = unixepoch() WHERE id = ?',
495
+ )
496
+ .bind(imageKey, id),
497
+ canonicalEditStatement(ctx.db, id),
498
+ ])
487
499
  }
488
500
 
489
501
  return json({
@@ -21,6 +21,7 @@ export interface AnalyticsRouteHandlers {
21
21
  topContent(ctx: RouteContext): Promise<Response>
22
22
  trafficSources(ctx: RouteContext): Promise<Response>
23
23
  articleDetail(ctx: RouteContext): Promise<Response>
24
+ podcastListening(ctx: RouteContext): Promise<Response>
24
25
  }
25
26
 
26
27
  /** Parse ?range=<Nd> from the request URL, defaulting to 30d. */
@@ -74,5 +75,25 @@ export function createAnalyticsRoutes(config: AnalyticsRouteConfig): AnalyticsRo
74
75
  const slug = url.searchParams.get('slug') ?? ''
75
76
  return json(await providers.analytics.getArticleDetail(slug, range))
76
77
  },
78
+
79
+ // Podcast listening panel — same publisher gate and ?range=<Nd> window
80
+ // as the other analytics handlers, but NOT a ProviderResult envelope:
81
+ // the provider returns null when the site has no podcast listening
82
+ // analytics at all, which this route answers with a 404 the screen
83
+ // renders as an explicit "not available" state. A provider that is
84
+ // implemented but fails throws — the route 500s loudly, never zeros.
85
+ async podcastListening(ctx: RouteContext): Promise<Response> {
86
+ const denied = await config.authz.requirePublisher(ctx)
87
+ if (denied) return denied
88
+ const range = parseRange(ctx.request.url)
89
+ const listening = await providers.analytics.getPodcastListening({
90
+ from: new Date(range.from * 1000).toISOString(),
91
+ to: new Date(range.to * 1000).toISOString(),
92
+ })
93
+ if (!listening) {
94
+ return json({ error: 'podcast listening analytics is not available' }, 404)
95
+ }
96
+ return json(listening)
97
+ },
77
98
  }
78
99
  }