@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
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Publication-timezone helpers — ONE implementation, shared by the schedule
3
+ * route and the Publish tab.
4
+ *
5
+ * Why this module exists: the editor types a wall-clock time and expects the
6
+ * post to go out at that time in the publication's timezone. That only holds
7
+ * if the digits the UI DISPLAYS and the zone it SAVES agree. When they
8
+ * disagreed, every resave shifted `publish_at` by the zone offset — including
9
+ * an incidental resave that never touched the field (the 2026-09-08 P0). So
10
+ * display and entry must use the same converter as the server, not a second
11
+ * copy of the arithmetic.
12
+ */
13
+
14
+ /** Fronts default; the server falls back to this when no zone is configured. */
15
+ export const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
16
+
17
+ /** `YYYY-MM-DDTHH:MM` exactly — the shape the date and time inputs produce. */
18
+ const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
19
+
20
+ type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
21
+
22
+ export function parseLocalDateTime(value: string): LocalDateTimeParts | null {
23
+ const match = LOCAL_DATE_TIME_RE.exec(value)
24
+ if (!match) return null
25
+ const [, y, m, d, h, min] = match
26
+ const parts: LocalDateTimeParts = {
27
+ year: Number.parseInt(y, 10),
28
+ month: Number.parseInt(m, 10),
29
+ day: Number.parseInt(d, 10),
30
+ hour: Number.parseInt(h, 10),
31
+ minute: Number.parseInt(min, 10),
32
+ }
33
+ if (
34
+ !Number.isInteger(parts.year) ||
35
+ parts.month < 1 ||
36
+ parts.month > 12 ||
37
+ parts.day < 1 ||
38
+ parts.day > 31 ||
39
+ parts.hour < 0 ||
40
+ parts.hour > 23 ||
41
+ parts.minute < 0 ||
42
+ parts.minute > 59
43
+ ) {
44
+ return null
45
+ }
46
+ const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
47
+ if (
48
+ dateCheck.getUTCFullYear() !== parts.year ||
49
+ dateCheck.getUTCMonth() + 1 !== parts.month ||
50
+ dateCheck.getUTCDate() !== parts.day
51
+ ) {
52
+ return null
53
+ }
54
+ return parts
55
+ }
56
+
57
+ export function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
58
+ if (!isValidTimeZone(timeZone)) return null
59
+ const formatter = new Intl.DateTimeFormat('en-US', {
60
+ timeZone,
61
+ hour12: false,
62
+ year: 'numeric',
63
+ month: '2-digit',
64
+ day: '2-digit',
65
+ hour: '2-digit',
66
+ minute: '2-digit',
67
+ second: '2-digit',
68
+ })
69
+ const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
70
+ for (const part of formatter.formatToParts(new Date(instantMs))) {
71
+ if (part.type in values) values[part.type as keyof typeof values] = part.value
72
+ }
73
+ const year = Number.parseInt(values.year, 10)
74
+ const month = Number.parseInt(values.month, 10)
75
+ const day = Number.parseInt(values.day, 10)
76
+ const hour = Number.parseInt(values.hour, 10)
77
+ const minute = Number.parseInt(values.minute, 10)
78
+ if (![year, month, day, hour, minute].every(Number.isFinite)) return null
79
+ return { year, month, day, hour, minute }
80
+ }
81
+
82
+ export function isValidTimeZone(timeZone: string): boolean {
83
+ try {
84
+ new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
85
+ return true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ export function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
92
+ const zoned = getZonedParts(instantMs, timeZone)
93
+ if (!zoned) return null
94
+ const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
95
+ return asUtc - instantMs
96
+ }
97
+
98
+ export function parseLocalDateTimeInZoneToUnixSeconds(
99
+ value: string,
100
+ timeZone: string,
101
+ ): number | null {
102
+ const target = parseLocalDateTime(value)
103
+ if (!target) return null
104
+ const targetUtcMs = Date.UTC(
105
+ target.year,
106
+ target.month - 1,
107
+ target.day,
108
+ target.hour,
109
+ target.minute,
110
+ 0,
111
+ )
112
+ let candidateMs = targetUtcMs
113
+ for (let i = 0; i < 5; i += 1) {
114
+ const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
115
+ if (offsetMs === null) return null
116
+ const next = targetUtcMs - offsetMs
117
+ if (Math.abs(next - candidateMs) < 1000) {
118
+ candidateMs = next
119
+ break
120
+ }
121
+ candidateMs = next
122
+ }
123
+ const resolved = getZonedParts(candidateMs, timeZone)
124
+ if (
125
+ !resolved ||
126
+ resolved.year !== target.year ||
127
+ resolved.month !== target.month ||
128
+ resolved.day !== target.day ||
129
+ resolved.hour !== target.hour ||
130
+ resolved.minute !== target.minute
131
+ ) {
132
+ return null
133
+ }
134
+ return Math.floor(candidateMs / 1000)
135
+ }
136
+
137
+ /**
138
+ * Inverse of `parseLocalDateTimeInZoneToUnixSeconds`: render an instant as the
139
+ * wall-clock digits an editor in `timeZone` would read, split for the date and
140
+ * time inputs. Returns null when the zone is unusable, so callers can fall back
141
+ * rather than silently display a wrong hour.
142
+ */
143
+ export function formatInstantInZone(
144
+ instantMs: number,
145
+ timeZone: string,
146
+ ): { date: string; time: string } | null {
147
+ const parts = getZonedParts(instantMs, timeZone)
148
+ if (!parts) return null
149
+ const pad = (n: number, width = 2) => String(n).padStart(width, '0')
150
+ return {
151
+ date: `${pad(parts.year, 4)}-${pad(parts.month)}-${pad(parts.day)}`,
152
+ time: `${pad(parts.hour)}:${pad(parts.minute)}`,
153
+ }
154
+ }
@@ -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
  }
@@ -66,6 +66,12 @@ import { invalidateMediaCaptureStatements, softDeleteContent } from '../engine/s
66
66
  import { normalizeSourceSections } from '../engine/source-sections.js'
67
67
  import { TagInputError } from '../engine/taxonomy.js'
68
68
  import type { ValidationError } from '../engine/validator/index.js'
69
+ import { VideoSourceNotIngestableError } from '../engine/video-source.js'
70
+ import {
71
+ isValidTimeZone,
72
+ PUBLISH_TIMEZONE_FALLBACK,
73
+ parseLocalDateTimeInZoneToUnixSeconds,
74
+ } from '../lib/publish-timezone.js'
69
75
  import type { MediaSourceResolution } from '../providers/types.js'
70
76
  import { parseLayoutEnvelope } from '../schema/layout.js'
71
77
  import { parsePortableTextEnvelope } from '../schema/portable-text.js'
@@ -74,6 +80,8 @@ import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
74
80
  import type { CmsRouteConfig } from './config.js'
75
81
  import { resolveConfig } from './config.js'
76
82
  import { json, type RouteContext, requireId } from './context.js'
83
+ import type { FoundryPublishOptions } from './foundry-publish.js'
84
+ import { isFoundryMachineContext } from './machine-context.js'
77
85
  import type { DispatchWebhookHook } from './webhooks.js'
78
86
 
79
87
  export type { DispatchWebhookHook }
@@ -141,6 +149,11 @@ function mediaSourceProviderErrorResponse(error: unknown): Response | null {
141
149
  return json({ error: failure.message, reason: failure.code }, failure.status)
142
150
  }
143
151
 
152
+ function videoSourceNotIngestableResponse(error: unknown): Response | null {
153
+ if (!(error instanceof VideoSourceNotIngestableError)) return null
154
+ return json({ error: error.message, reason: 'video_source_not_ingestable' }, 400)
155
+ }
156
+
144
157
  function verifiedMediaDurationConflictResponse(error: unknown): Response | null {
145
158
  if (!(error instanceof VerifiedMediaDurationConflictError)) return null
146
159
  return json(
@@ -226,6 +239,14 @@ export interface ContentRouteConfig extends CmsRouteConfig {
226
239
  videoSourceProductionEnabled?: boolean
227
240
  /** Internal machine-route constraint: atomically update only in this status. */
228
241
  updateStatusConstraint?: ContentStatus
242
+ /**
243
+ * Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
244
+ * commit step (publishContent + createRevision) with the caller's — publishOne
245
+ * with the publication's live surfaces. Every other step of the native publish
246
+ * action (readiness, body guard, dek, tag check, activity log, takeaways, FAQ,
247
+ * the content.published webhook) runs unchanged, through the same handler.
248
+ */
249
+ publishCommit?: (ctx: RouteContext, input: { contentId: string; now: number }) => Promise<void>
229
250
  /**
230
251
  * Optional webhook dispatcher hook (P7 Task 9). Fired at each content
231
252
  * lifecycle transition (publish, unpublish, schedule, archive, create, etc.).
@@ -240,11 +261,23 @@ export interface ContentRouteConfig extends CmsRouteConfig {
240
261
  * when absent so callers without a workspace concept still produce a scoped row.
241
262
  */
242
263
  activeWorkspaceId?: string
264
+ /**
265
+ * Per-publication opt-in for the Foundry machine route's publish transition
266
+ * (M3-18). Omitted, or `autoPublish` anything but `true`, keeps that route
267
+ * draft-only and byte-identical to the v1 route. Read only by routes/foundry-publish.ts.
268
+ */
269
+ foundryPublish?: FoundryPublishOptions
270
+ /**
271
+ * This publication's canonical host for the Foundry route family, declared
272
+ * statically (the integration's `foundryCanonicalHost`, so it reaches every
273
+ * request whatever the host's per-request hooks set). Once a host is declared
274
+ * here or in `foundryPublish.canonicalHost`, the Foundry route verifies only
275
+ * the host-bound `foundry-cms-v2` string and never the legacy dot form.
276
+ * Read only by routes/foundry-publish.ts.
277
+ */
278
+ foundryCanonicalHost?: string
243
279
  }
244
280
 
245
- const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
246
- const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
247
-
248
281
  type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
249
282
 
250
283
  /**
@@ -420,6 +453,8 @@ const NEXTCLOUD_SHARE_HOSTS = ['drive.fulcrum-portal.com']
420
453
  const NEXTCLOUD_SHARE_PATH_RE = /^\/s\/([A-Za-z0-9_-]+)(?:\/download)?\/?$/
421
454
  const NEXTCLOUD_DIRECT_PATH_RE = /^\/public\.php\/dav\/files\/[A-Za-z0-9_-]+$/
422
455
 
456
+ const NEXTCLOUD_SHARE_NOISE_PARAMS: ReadonlySet<string> = new Set(['dir', 'editing', 'openfile'])
457
+
423
458
  export function normaliseNextcloudShareUrl(value: string): string {
424
459
  let url: URL
425
460
  try {
@@ -438,13 +473,19 @@ export function normaliseNextcloudShareUrl(value: string): string {
438
473
  ) {
439
474
  return value
440
475
  }
441
- const onlyDirQuery =
442
- url.search === '' || (url.searchParams.get('dir') !== null && url.searchParams.size === 1)
476
+ // Nextcloud's copy-link output adds page-navigation noise -- `?dir=/` from a
477
+ // folder listing, `?dir=/&editing=false&openfile=true` from the viewer --
478
+ // none of which changes the file a share serves. Any other parameter may
479
+ // select a different resource, so the URL is kept and the publisher's
480
+ // ingest guard refuses it (HomeFronts 2026-10-01T18:05Z).
481
+ const onlyNoiseQuery =
482
+ url.search === '' ||
483
+ [...url.searchParams.keys()].every((key) => NEXTCLOUD_SHARE_NOISE_PARAMS.has(key))
443
484
  if (NEXTCLOUD_DIRECT_PATH_RE.test(url.pathname)) {
444
- return url.search === '' ? value : onlyDirQuery ? `https://${host}${url.pathname}` : value
485
+ return url.search === '' ? value : onlyNoiseQuery ? `https://${host}${url.pathname}` : value
445
486
  }
446
487
  const share = NEXTCLOUD_SHARE_PATH_RE.exec(url.pathname)
447
- if (!share || !onlyDirQuery) return value
488
+ if (!share || !onlyNoiseQuery) return value
448
489
  return `https://${host}/public.php/dav/files/${share[1]}`
449
490
  }
450
491
 
@@ -677,119 +718,6 @@ const ContributorsReplaceSchema = z.object({
677
718
 
678
719
  // --- timezone-aware local-datetime parsing (ported verbatim) --------------
679
720
 
680
- type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
681
-
682
- function parseLocalDateTime(value: string): LocalDateTimeParts | null {
683
- const match = LOCAL_DATE_TIME_RE.exec(value)
684
- if (!match) return null
685
- const [, y, m, d, h, min] = match
686
- const parts: LocalDateTimeParts = {
687
- year: Number.parseInt(y, 10),
688
- month: Number.parseInt(m, 10),
689
- day: Number.parseInt(d, 10),
690
- hour: Number.parseInt(h, 10),
691
- minute: Number.parseInt(min, 10),
692
- }
693
- if (
694
- !Number.isInteger(parts.year) ||
695
- parts.month < 1 ||
696
- parts.month > 12 ||
697
- parts.day < 1 ||
698
- parts.day > 31 ||
699
- parts.hour < 0 ||
700
- parts.hour > 23 ||
701
- parts.minute < 0 ||
702
- parts.minute > 59
703
- ) {
704
- return null
705
- }
706
- const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
707
- if (
708
- dateCheck.getUTCFullYear() !== parts.year ||
709
- dateCheck.getUTCMonth() + 1 !== parts.month ||
710
- dateCheck.getUTCDate() !== parts.day
711
- ) {
712
- return null
713
- }
714
- return parts
715
- }
716
-
717
- function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
718
- const formatter = new Intl.DateTimeFormat('en-US', {
719
- timeZone,
720
- hour12: false,
721
- year: 'numeric',
722
- month: '2-digit',
723
- day: '2-digit',
724
- hour: '2-digit',
725
- minute: '2-digit',
726
- second: '2-digit',
727
- })
728
- const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
729
- for (const part of formatter.formatToParts(new Date(instantMs))) {
730
- if (part.type in values) values[part.type as keyof typeof values] = part.value
731
- }
732
- const year = Number.parseInt(values.year, 10)
733
- const month = Number.parseInt(values.month, 10)
734
- const day = Number.parseInt(values.day, 10)
735
- const hour = Number.parseInt(values.hour, 10)
736
- const minute = Number.parseInt(values.minute, 10)
737
- if (![year, month, day, hour, minute].every(Number.isFinite)) return null
738
- return { year, month, day, hour, minute }
739
- }
740
-
741
- function isValidTimeZone(timeZone: string): boolean {
742
- try {
743
- new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
744
- return true
745
- } catch {
746
- return false
747
- }
748
- }
749
-
750
- function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
751
- const zoned = getZonedParts(instantMs, timeZone)
752
- if (!zoned) return null
753
- const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
754
- return asUtc - instantMs
755
- }
756
-
757
- function parseLocalDateTimeInZoneToUnixSeconds(value: string, timeZone: string): number | null {
758
- const target = parseLocalDateTime(value)
759
- if (!target) return null
760
- const targetUtcMs = Date.UTC(
761
- target.year,
762
- target.month - 1,
763
- target.day,
764
- target.hour,
765
- target.minute,
766
- 0,
767
- )
768
- let candidateMs = targetUtcMs
769
- for (let i = 0; i < 5; i += 1) {
770
- const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
771
- if (offsetMs === null) return null
772
- const next = targetUtcMs - offsetMs
773
- if (Math.abs(next - candidateMs) < 1000) {
774
- candidateMs = next
775
- break
776
- }
777
- candidateMs = next
778
- }
779
- const resolved = getZonedParts(candidateMs, timeZone)
780
- if (
781
- !resolved ||
782
- resolved.year !== target.year ||
783
- resolved.month !== target.month ||
784
- resolved.day !== target.day ||
785
- resolved.hour !== target.hour ||
786
- resolved.minute !== target.minute
787
- ) {
788
- return null
789
- }
790
- return Math.floor(candidateMs / 1000)
791
- }
792
-
793
721
  // --- read-side payload helpers (ported verbatim) --------------------------
794
722
 
795
723
  async function getContentPayload(ctx: RouteContext, type: string, id: string) {
@@ -1293,6 +1221,15 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1293
1221
  * — identical guard pattern to dispatchTakeaways / logActivity.
1294
1222
  * When no hook is injected this is a no-op (no 501, no error).
1295
1223
  */
1224
+ /**
1225
+ * Only a context the HMAC-verified Foundry route marked is the machine
1226
+ * (routes/machine-context.ts); a subject or userId string never decides it,
1227
+ * so no person can act as the machine. Any other actor is a person.
1228
+ */
1229
+ async function isFoundryActor(ctx: RouteContext): Promise<boolean> {
1230
+ return isFoundryMachineContext(ctx)
1231
+ }
1232
+
1296
1233
  async function fireWebhook(
1297
1234
  ctx: RouteContext,
1298
1235
  eventType: string,
@@ -1536,12 +1473,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1536
1473
  if (op === 'topic') {
1537
1474
  const { topic } = parsed.data
1538
1475
  for (const id of ids) {
1539
- await ctx.db
1540
- .prepare(
1541
- 'UPDATE content_items SET primary_topic = ?, updated_at = unixepoch() WHERE id = ? AND deleted_at IS NULL',
1542
- )
1543
- .bind(topic, id)
1544
- .run()
1476
+ // Through the editor's own save path, so it is a canonical edit; the
1477
+ // existing category is passed through so the save changes nothing else.
1478
+ const existing = await getContentItem(ctx.db, id)
1479
+ if (!existing || (existing as { deleted_at?: number | null }).deleted_at != null) continue
1480
+ await updateContentItem(ctx.db, id, {
1481
+ primaryTopic: topic,
1482
+ primaryCategory: existing.channel,
1483
+ })
1545
1484
  }
1546
1485
  return json({ updated: ids })
1547
1486
  }
@@ -1643,6 +1582,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1643
1582
  created = await createContent(ctx.db, parsed.data)
1644
1583
  } catch (error) {
1645
1584
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
1585
+ const notIngestable = videoSourceNotIngestableResponse(error)
1586
+ if (notIngestable) return notIngestable
1646
1587
  const conflict = verifiedMediaDurationConflictResponse(error)
1647
1588
  if (conflict) return conflict
1648
1589
  throw error
@@ -1889,6 +1830,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1889
1830
  })
1890
1831
  } catch (error) {
1891
1832
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
1833
+ const notIngestable = videoSourceNotIngestableResponse(error)
1834
+ if (notIngestable) return notIngestable
1892
1835
  const conflict = verifiedMediaDurationConflictResponse(error)
1893
1836
  if (conflict) return conflict
1894
1837
  throw error
@@ -2145,8 +2088,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2145
2088
  throw error
2146
2089
  }
2147
2090
  try {
2148
- await publishContent(ctx.db, id, publishedAt, now)
2149
- await createRevision(ctx.db, id, ctx.userId || null)
2091
+ if (config.publishCommit) {
2092
+ await config.publishCommit(ctx, { contentId: id, now })
2093
+ } else {
2094
+ await publishContent(ctx.db, id, publishedAt, now)
2095
+ await createRevision(ctx.db, id, ctx.userId || null)
2096
+ }
2150
2097
  } catch (error) {
2151
2098
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
2152
2099
  throw error
@@ -2238,7 +2185,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2238
2185
  }
2239
2186
 
2240
2187
  if (action === 'unpublish') {
2241
- await unpublishContent(ctx.db, id)
2188
+ // A person's unpublish sticks: it is a canonical edit, so a machine
2189
+ // publisher cannot put the item back without a new write. The
2190
+ // machine's own unpublish is not, so it can republish its item.
2191
+ await unpublishContent(ctx.db, id, { canonicalEdit: !(await isFoundryActor(ctx)) })
2242
2192
 
2243
2193
  // Fire content.unpublished webhook.
2244
2194
  await fireWebhook(ctx, 'content.unpublished', {
@@ -2274,9 +2224,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2274
2224
  if (action === 'unschedule') {
2275
2225
  // publish_tz is NOT NULL (DEFAULT 'Europe/Paris'); reset it to the
2276
2226
  // default rather than nulling it (a NULL violates the constraint).
2227
+ // A person pulling a scheduled item is a canonical edit (see unpublish).
2228
+ const pulledByPerson = !(await isFoundryActor(ctx))
2277
2229
  await ctx.db
2278
2230
  .prepare(
2279
- "UPDATE content_items SET status = 'draft', publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch() WHERE id = ?",
2231
+ `UPDATE content_items SET status = 'draft', publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch()${pulledByPerson ? ', canonical_version = canonical_version + 1' : ''} WHERE id = ?`,
2280
2232
  )
2281
2233
  .bind(id)
2282
2234
  .run()
@@ -2315,9 +2267,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2315
2267
  // publish_at so a de-scheduled item doesn't carry a phantom publish time.
2316
2268
  // publish_tz is NOT NULL (DEFAULT 'Europe/Paris'), so it is reset to the
2317
2269
  // default rather than nulled.
2270
+ // A person moving the item back to draft or review is a canonical
2271
+ // edit (see unpublish); the machine never calls set-status.
2272
+ const movedByPerson = !(await isFoundryActor(ctx))
2318
2273
  await ctx.db
2319
2274
  .prepare(
2320
- "UPDATE content_items SET status = ?, publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch() WHERE id = ?",
2275
+ `UPDATE content_items SET status = ?, publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch()${movedByPerson ? ', canonical_version = canonical_version + 1' : ''} WHERE id = ?`,
2321
2276
  )
2322
2277
  .bind(status, id)
2323
2278
  .run()