@growth-labs/cms 0.5.29 → 0.5.31

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 (151) hide show
  1. package/dist/engine/foundry-dispatch.d.ts +7 -2
  2. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  3. package/dist/engine/foundry-dispatch.js +24 -6
  4. package/dist/engine/foundry-dispatch.js.map +1 -1
  5. package/dist/engine/fronts-publish-intent.d.ts +174 -0
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
  7. package/dist/engine/fronts-publish-intent.js +352 -0
  8. package/dist/engine/fronts-publish-intent.js.map +1 -0
  9. package/dist/engine/fronts-publish.d.ts +80 -0
  10. package/dist/engine/fronts-publish.d.ts.map +1 -0
  11. package/dist/engine/fronts-publish.js +286 -0
  12. package/dist/engine/fronts-publish.js.map +1 -0
  13. package/dist/engine/index.d.ts +7 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +10 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publication-continuity.d.ts +77 -0
  18. package/dist/engine/publication-continuity.d.ts.map +1 -0
  19. package/dist/engine/publication-continuity.js +94 -0
  20. package/dist/engine/publication-continuity.js.map +1 -0
  21. package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
  22. package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
  23. package/dist/engine/publication-receipt/canonical-json.js +100 -0
  24. package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
  25. package/dist/engine/publication-receipt/receipt.d.ts +316 -0
  26. package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
  27. package/dist/engine/publication-receipt/receipt.js +131 -0
  28. package/dist/engine/publication-receipt/receipt.js.map +1 -0
  29. package/dist/engine/publication-receipt/signature.d.ts +36 -0
  30. package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
  31. package/dist/engine/publication-receipt/signature.js +79 -0
  32. package/dist/engine/publication-receipt/signature.js.map +1 -0
  33. package/dist/engine/publication.d.ts.map +1 -1
  34. package/dist/engine/publication.js +2 -0
  35. package/dist/engine/publication.js.map +1 -1
  36. package/dist/engine/publisher.d.ts.map +1 -1
  37. package/dist/engine/publisher.js +23 -1
  38. package/dist/engine/publisher.js.map +1 -1
  39. package/dist/engine/source-sections.d.ts +20 -0
  40. package/dist/engine/source-sections.d.ts.map +1 -0
  41. package/dist/engine/source-sections.js +130 -0
  42. package/dist/engine/source-sections.js.map +1 -0
  43. package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
  44. package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
  45. package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
  46. package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
  47. package/dist/engine/validator/index.d.ts +10 -0
  48. package/dist/engine/validator/index.d.ts.map +1 -1
  49. package/dist/engine/validator/index.js +4 -0
  50. package/dist/engine/validator/index.js.map +1 -1
  51. package/dist/engine/validator/types.d.ts +1 -1
  52. package/dist/engine/validator/types.d.ts.map +1 -1
  53. package/dist/engine/validator/types.js +10 -0
  54. package/dist/engine/validator/types.js.map +1 -1
  55. package/dist/integration/index.d.ts.map +1 -1
  56. package/dist/integration/index.js +10 -0
  57. package/dist/integration/index.js.map +1 -1
  58. package/dist/providers/types.d.ts +1 -0
  59. package/dist/providers/types.d.ts.map +1 -1
  60. package/dist/routes/content.d.ts.map +1 -1
  61. package/dist/routes/content.js +22 -2
  62. package/dist/routes/content.js.map +1 -1
  63. package/dist/routes/fronts-publish.d.ts +10 -0
  64. package/dist/routes/fronts-publish.d.ts.map +1 -0
  65. package/dist/routes/fronts-publish.js +250 -0
  66. package/dist/routes/fronts-publish.js.map +1 -0
  67. package/dist/routes/index.d.ts +3 -0
  68. package/dist/routes/index.d.ts.map +1 -1
  69. package/dist/routes/index.js +4 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/surveys.d.ts.map +1 -1
  72. package/dist/routes/surveys.js +13 -1
  73. package/dist/routes/surveys.js.map +1 -1
  74. package/dist/schema/insights-ingest.d.ts +28 -28
  75. package/dist/schema/layout.d.ts +2 -2
  76. package/dist/schema/migrations.d.ts.map +1 -1
  77. package/dist/schema/migrations.js +36 -0
  78. package/dist/schema/migrations.js.map +1 -1
  79. package/dist/schema/portable-text.d.ts +2 -2
  80. package/dist/schema/tables.d.ts +1 -1
  81. package/dist/schema/tables.d.ts.map +1 -1
  82. package/dist/schema/tables.js +2 -0
  83. package/dist/schema/tables.js.map +1 -1
  84. package/dist/schema/types.d.ts +59 -0
  85. package/dist/schema/types.d.ts.map +1 -1
  86. package/dist/schema/types.js.map +1 -1
  87. package/dist/surveys/access.d.ts +2 -0
  88. package/dist/surveys/access.d.ts.map +1 -1
  89. package/dist/surveys/access.js +7 -0
  90. package/dist/surveys/access.js.map +1 -1
  91. package/dist/surveys/errors.d.ts +8 -0
  92. package/dist/surveys/errors.d.ts.map +1 -1
  93. package/dist/surveys/errors.js +12 -0
  94. package/dist/surveys/errors.js.map +1 -1
  95. package/dist/surveys/schema.d.ts +129 -54
  96. package/dist/surveys/schema.d.ts.map +1 -1
  97. package/dist/surveys/schema.js +45 -0
  98. package/dist/surveys/schema.js.map +1 -1
  99. package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
  100. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
  101. package/dist/ui/api/fronts/publish-callback.js +8 -0
  102. package/dist/ui/api/fronts/publish-callback.js.map +1 -0
  103. package/dist/ui/api/fronts/schedule.d.ts +3 -0
  104. package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
  105. package/dist/ui/api/fronts/schedule.js +8 -0
  106. package/dist/ui/api/fronts/schedule.js.map +1 -0
  107. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  108. package/dist/ui/editor/ContentForm.js +52 -1
  109. package/dist/ui/editor/ContentForm.js.map +1 -1
  110. package/dist/ui/editor/body-warnings.d.ts +27 -0
  111. package/dist/ui/editor/body-warnings.d.ts.map +1 -0
  112. package/dist/ui/editor/body-warnings.js +58 -0
  113. package/dist/ui/editor/body-warnings.js.map +1 -0
  114. package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
  115. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  116. package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
  117. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  118. package/migrations/0026_fronts_publish.sql +31 -0
  119. package/migrations/0027_fronts_publish_intent.sql +44 -0
  120. package/package.json +1 -1
  121. package/src/engine/foundry-dispatch.ts +22 -6
  122. package/src/engine/fronts-publish-intent.ts +676 -0
  123. package/src/engine/fronts-publish.ts +417 -0
  124. package/src/engine/index.ts +60 -0
  125. package/src/engine/publication-continuity.ts +140 -0
  126. package/src/engine/publication-receipt/canonical-json.ts +123 -0
  127. package/src/engine/publication-receipt/receipt.ts +160 -0
  128. package/src/engine/publication-receipt/signature.ts +112 -0
  129. package/src/engine/publication.ts +2 -0
  130. package/src/engine/publisher.ts +23 -1
  131. package/src/engine/source-sections.ts +153 -0
  132. package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
  133. package/src/engine/validator/index.ts +12 -0
  134. package/src/engine/validator/types.ts +19 -0
  135. package/src/integration/index.ts +10 -0
  136. package/src/providers/types.ts +1 -1
  137. package/src/routes/content.ts +23 -2
  138. package/src/routes/fronts-publish.ts +292 -0
  139. package/src/routes/index.ts +8 -0
  140. package/src/routes/surveys.ts +12 -1
  141. package/src/schema/migrations.ts +36 -0
  142. package/src/schema/tables.ts +2 -0
  143. package/src/schema/types.ts +61 -0
  144. package/src/surveys/access.ts +9 -0
  145. package/src/surveys/errors.ts +12 -0
  146. package/src/surveys/schema.ts +47 -0
  147. package/src/ui/api/fronts/publish-callback.ts +18 -0
  148. package/src/ui/api/fronts/schedule.ts +18 -0
  149. package/src/ui/editor/ContentForm.tsx +93 -0
  150. package/src/ui/editor/body-warnings.ts +78 -0
  151. package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Source-section diagnostics (#329).
3
+ *
4
+ * These run against markdown that has ALREADY been through
5
+ * `normalizeSourceSections()`. That ordering is the whole point: the
6
+ * normalizer fixes everything mechanically fixable, so anything still present
7
+ * here needs an editor's judgement rather than another rewrite — and each of
8
+ * these is a proven render breaker, which is why they block publish.
9
+ *
10
+ * Draft autosave stays allowed regardless; an editor must never lose work to a
11
+ * formatting complaint.
12
+ */
13
+ import { normalizeSourceSections } from '../../source-sections.js'
14
+ import type {
15
+ ArticleBodyInput,
16
+ ValidationError,
17
+ ValidationErrorCode,
18
+ ValidationSpan,
19
+ } from '../types.js'
20
+ import { makeError } from '../types.js'
21
+
22
+ export interface SourceSectionOptions {
23
+ /** Escalate these diagnostics from warn to block. Publish sets this. */
24
+ blockSourceSectionDefects?: boolean
25
+ }
26
+
27
+ const NBSP = ' '
28
+ const MANUAL_BULLET = /^\s*[●•▪]/u
29
+ const LINK = /\[([^\]]*)\]\(([^)]*)\)/g
30
+
31
+ interface SectionLine {
32
+ text: string
33
+ /** Byte offset of this line in the source document. */
34
+ start: number
35
+ }
36
+
37
+ /** Lines inside every Sources/References section, with document offsets. */
38
+ function collectSectionLines(source: string): SectionLine[] {
39
+ const lines = source.split('\n')
40
+ const collected: SectionLine[] = []
41
+ let offset = 0
42
+ let sectionLevel: number | null = null
43
+
44
+ for (const text of lines) {
45
+ const heading = /^(#{1,6})\s+(.*)$/.exec(text)
46
+ const level = heading ? (heading[1]?.length ?? 0) : null
47
+
48
+ if (sectionLevel !== null && level !== null && level <= sectionLevel) {
49
+ sectionLevel = null
50
+ }
51
+
52
+ if (sectionLevel !== null) {
53
+ collected.push({ text, start: offset })
54
+ } else if (level !== null) {
55
+ const word = (heading?.[2] ?? '')
56
+ .replace(/[*_]/g, '')
57
+ .replace(/\s*:\s*$/, '')
58
+ .trim()
59
+ if (/^(sources|references)$/i.test(word)) sectionLevel = level
60
+ }
61
+
62
+ offset += text.length + 1
63
+ }
64
+
65
+ return collected
66
+ }
67
+
68
+ function isFormattingOnly(text: string): boolean {
69
+ if (text.trim() === '') return false
70
+ return (
71
+ text
72
+ .replaceAll(NBSP, ' ')
73
+ .replace(/[*_~`#]/g, '')
74
+ .trim() === ''
75
+ )
76
+ }
77
+
78
+ /** Emphasis delimiters wrapping the entire label, e.g. `[**t**](u)`. */
79
+ function hasWholeLabelEmphasis(label: string): boolean {
80
+ const match = /^(\*\*|__|\*|_)([\s\S]+)\1$/.exec(label.trim())
81
+ if (!match) return false
82
+ return !(match[2] ?? '').includes(match[1] as string)
83
+ }
84
+
85
+ /** An odd number of `**` or `*` runs — ambiguous, so the normalizer left it. */
86
+ function hasUnbalancedEmphasis(label: string): boolean {
87
+ const strong = (label.match(/\*\*/g) ?? []).length
88
+ if (strong % 2 !== 0) return true
89
+ const em = (label.replace(/\*\*/g, '').match(/\*/g) ?? []).length
90
+ if (em % 2 !== 0) return true
91
+ const under = (label.match(/__/g) ?? []).length
92
+ return under % 2 !== 0
93
+ }
94
+
95
+ export function check(
96
+ input: ArticleBodyInput,
97
+ options: SourceSectionOptions = {},
98
+ ): ValidationError[] {
99
+ // Warn on save, block on publish. The design keeps draft autosave working
100
+ // so an editor cannot lose work to a formatting complaint, while still
101
+ // refusing to publish a section that is a proven render breaker.
102
+ const escalate = options.blockSourceSectionDefects === true
103
+ const report = (
104
+ code: ValidationErrorCode,
105
+ message: string,
106
+ span?: ValidationSpan,
107
+ ): ValidationError => {
108
+ const error = makeError(code, message, span)
109
+ return escalate ? { ...error, severity: 'block' } : error
110
+ }
111
+
112
+ if (!input.body_markdown) return []
113
+
114
+ // Report against normalized text so a defect the normalizer would have
115
+ // fixed is never surfaced to the editor as their problem.
116
+ const source = normalizeSourceSections(input.body_markdown).markdown
117
+ const errors: ValidationError[] = []
118
+ const lines = collectSectionLines(source)
119
+
120
+ let linkLike = 0
121
+ let listItems = 0
122
+
123
+ for (const { text, start } of lines) {
124
+ const span = { start, end: start + text.length }
125
+
126
+ if (MANUAL_BULLET.test(text)) {
127
+ errors.push(
128
+ report(
129
+ 'source-section-manual-bullet',
130
+ `This source line still starts with a manual bullet character. Replace it with "- " so it renders as one list, not separate paragraphs.`,
131
+ span,
132
+ ),
133
+ )
134
+ }
135
+
136
+ if (isFormattingOnly(text)) {
137
+ errors.push(
138
+ report(
139
+ 'source-section-formatting-only-line',
140
+ `This line contains only formatting characters and renders as an empty heading. Delete it.`,
141
+ span,
142
+ ),
143
+ )
144
+ }
145
+
146
+ if (/^\s*[-*+]\s+/.test(text)) listItems++
147
+
148
+ for (const link of text.matchAll(LINK)) {
149
+ const label = link[1] ?? ''
150
+ if (label.trim() !== '') linkLike++
151
+
152
+ if (hasWholeLabelEmphasis(label)) {
153
+ errors.push(
154
+ report(
155
+ 'source-section-wrapped-link-label',
156
+ `The link title "${label.trim()}" is wrapped in emphasis markers, which stay visible inside the link. Remove the surrounding * or _ characters.`,
157
+ span,
158
+ ),
159
+ )
160
+ } else if (hasUnbalancedEmphasis(label)) {
161
+ errors.push(
162
+ report(
163
+ 'source-section-unbalanced-emphasis',
164
+ `The link title "${label.trim()}" has an unmatched emphasis marker. Close it or remove it — it cannot be corrected automatically without changing the title.`,
165
+ span,
166
+ ),
167
+ )
168
+ }
169
+ }
170
+ }
171
+
172
+ // A section that reads as a list of sources but has no list items at all
173
+ // renders as a run of paragraphs. Only claim this when there is something
174
+ // list-shaped to talk about.
175
+ if (listItems === 0 && linkLike > 1) {
176
+ const first = lines[0]
177
+ errors.push(
178
+ report(
179
+ 'source-section-unlistable',
180
+ `This source section has ${linkLike} links but no list items, so it renders as separate paragraphs. Start each source line with "- ".`,
181
+ first ? { start: first.start, end: first.start + first.text.length } : undefined,
182
+ ),
183
+ )
184
+ }
185
+
186
+ return errors
187
+ }
@@ -21,6 +21,7 @@ import * as iframeMissingDims from './checks/iframe-missing-dims-and-wrapper.js'
21
21
  import * as invisibleControl from './checks/invisible-control-chars.js'
22
22
  import * as paywallMarker from './checks/paywall-marker-leak.js'
23
23
  import * as rawBlockHtml from './checks/raw-block-html.js'
24
+ import * as sourceSectionDiagnostics from './checks/source-section-diagnostics.js'
24
25
  import * as unresolvedAnchor from './checks/unresolved-footnote-anchor.js'
25
26
  import * as wordGdocs from './checks/word-gdocs-paste-artifacts.js'
26
27
 
@@ -302,6 +303,7 @@ export const checkBareUrlNotAutolinked = bareUrl.check
302
303
  export const checkEmptyAltText = emptyAlt.check
303
304
  export const checkIframeMissingDimsAndWrapper = iframeMissingDims.check
304
305
  export const checkHeadingHierarchySkip = headingHierarchy.check
306
+ export const checkSourceSectionDiagnostics = sourceSectionDiagnostics.check
305
307
  export const checkPaywallMarkerLeak = paywallMarker.check
306
308
 
307
309
  /* -------------------------------------------------------------------------- */
@@ -310,11 +312,20 @@ export const checkPaywallMarkerLeak = paywallMarker.check
310
312
 
311
313
  export interface RunOptions extends ValidatorOptions {
312
314
  requireBody: boolean
315
+ /**
316
+ * Escalate source-section diagnostics (#329) from warn to block.
317
+ *
318
+ * Defaults to false so a draft save is never rejected for formatting — the
319
+ * PATCH route returns 422 on any block-severity error. Publish call sites
320
+ * set it to true, which is where the design wants these enforced.
321
+ */
322
+ blockSourceSectionDefects: boolean
313
323
  }
314
324
 
315
325
  export const DEFAULT_RUN_OPTIONS: RunOptions = {
316
326
  ...DEFAULT_OPTIONS,
317
327
  requireBody: true,
328
+ blockSourceSectionDefects: false,
318
329
  }
319
330
 
320
331
  /**
@@ -349,6 +360,7 @@ export function validateArticleBody(
349
360
  errors.push(...checkEmptyAltText(input))
350
361
  errors.push(...checkIframeMissingDimsAndWrapper(input))
351
362
  errors.push(...checkHeadingHierarchySkip(input))
363
+ errors.push(...checkSourceSectionDiagnostics(input, opts))
352
364
  errors.push(...checkPaywallMarkerLeak(input))
353
365
  // Heuristic.
354
366
  errors.push(...checkUnbalancedMarkdownEmphasis(input))
@@ -55,6 +55,15 @@ export type ValidationErrorCode =
55
55
  | 'heading-hierarchy-skip'
56
56
  | 'paywall-marker-leak'
57
57
  | 'stale-body-html'
58
+ // Source-section diagnostics (#329). Reported only for defects that
59
+ // SURVIVE normalizeSourceSections() — the normalizer already fixed
60
+ // everything mechanically fixable, so what is left needs an editor's
61
+ // judgement. Warn on save, block on publish; see SEVERITY below.
62
+ | 'source-section-manual-bullet'
63
+ | 'source-section-formatting-only-line'
64
+ | 'source-section-wrapped-link-label'
65
+ | 'source-section-unbalanced-emphasis'
66
+ | 'source-section-unlistable'
58
67
 
59
68
  export type ValidationResult = { ok: true } | { ok: false; errors: ValidationError[] }
60
69
 
@@ -88,6 +97,16 @@ export const SEVERITY: Readonly<Record<ValidationErrorCode, Severity>> = {
88
97
  'heading-hierarchy-skip': 'warn',
89
98
  'paywall-marker-leak': 'block',
90
99
  'stale-body-html': 'block',
100
+ // #329. WARN by default so a draft save is never rejected — the design is
101
+ // explicit that an editor must not lose work to a formatting complaint, and
102
+ // the PATCH route returns 422 on any block-severity error. Publish call
103
+ // sites pass `blockSourceSectionDefects` to escalate these to block, which
104
+ // is where the design wants them enforced.
105
+ 'source-section-manual-bullet': 'warn',
106
+ 'source-section-formatting-only-line': 'warn',
107
+ 'source-section-wrapped-link-label': 'warn',
108
+ 'source-section-unbalanced-emphasis': 'warn',
109
+ 'source-section-unlistable': 'warn',
91
110
  }
92
111
 
93
112
  export interface ValidatorOptions {
@@ -102,6 +102,16 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
102
102
  pattern: `${base}/api/content/insights-ingest`,
103
103
  entrypoint: resolveEntrypoint('../ui/api/content/insights-ingest'),
104
104
  })
105
+ // Fronts publish path (additive, flag-gated) — M2M siblings of the
106
+ // foundry-callback / insights-ingest routes. HMAC-guarded.
107
+ injectRoute({
108
+ pattern: `${base}/api/fronts/schedule`,
109
+ entrypoint: resolveEntrypoint('../ui/api/fronts/schedule'),
110
+ })
111
+ injectRoute({
112
+ pattern: `${base}/api/fronts/publish-callback`,
113
+ entrypoint: resolveEntrypoint('../ui/api/fronts/publish-callback'),
114
+ })
105
115
  // Content list + create (Library screen)
106
116
  injectRoute({
107
117
  pattern: `${base}/api/content`,
@@ -430,7 +430,7 @@ export interface SettingsProvider {
430
430
  }
431
431
 
432
432
  export interface SurveyProvider {
433
- getResults(input: { surveyId: string }): Promise<SurveyResults>
433
+ getResults(input: { surveyId: string; offset?: number }): Promise<SurveyResults>
434
434
  getCsv(input: { surveyId: string }): Promise<SurveyCsvResult>
435
435
  }
436
436
 
@@ -56,6 +56,7 @@ import {
56
56
  } from '../engine/revisions.js'
57
57
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
58
58
  import { softDeleteContent } from '../engine/soft-delete.js'
59
+ import { normalizeSourceSections } from '../engine/source-sections.js'
59
60
  import { TagInputError } from '../engine/taxonomy.js'
60
61
  import type { ValidationError } from '../engine/validator/index.js'
61
62
  import { parseLayoutEnvelope } from '../schema/layout.js'
@@ -1258,9 +1259,29 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1258
1259
  bodyMarkdown?: string
1259
1260
  bodyHtml?: string | null
1260
1261
  }
1261
- const bodyMarkdown = articleContent.bodyMarkdown
1262
- const bodyHtml = articleContent.bodyHtml
1262
+ let bodyMarkdown = articleContent.bodyMarkdown
1263
+ let bodyHtml = articleContent.bodyHtml
1263
1264
  const isDraft = existing.status === 'draft'
1265
+
1266
+ // Safe, deterministic Sources/References cleanup is applied before
1267
+ // the body is persisted, so the stored Markdown is canonical and the
1268
+ // editor's next read shows what will actually render (#329).
1269
+ //
1270
+ // When normalization changes the Markdown, any body_html the client
1271
+ // sent alongside it was rendered from the pre-normalization text and
1272
+ // is stale by construction. Persisting it would let the exact defect
1273
+ // this fixes survive in the rendered output, which is how the
1274
+ // original incident reached a published page — so it is dropped and
1275
+ // re-rendered downstream.
1276
+ if (typeof bodyMarkdown === 'string') {
1277
+ const normalized = normalizeSourceSections(bodyMarkdown)
1278
+ if (normalized.changed) {
1279
+ bodyMarkdown = normalized.markdown
1280
+ bodyHtml = null
1281
+ articleContent.bodyMarkdown = normalized.markdown
1282
+ articleContent.bodyHtml = null
1283
+ }
1284
+ }
1264
1285
  if (bodyMarkdown !== undefined || bodyHtml !== undefined) {
1265
1286
  const guard = evaluateArticleBody(
1266
1287
  { body_markdown: bodyMarkdown ?? null, body_html: bodyHtml ?? null },
@@ -0,0 +1,292 @@
1
+ // Fronts publish routes (cms/Masthead side) — the two machine-to-machine seams
2
+ // the out-of-process Fronts publish worker (foundryd) drives:
3
+ //
4
+ // GET /admin/api/fronts/schedule?window=<from>..<to> — the poll surface.
5
+ // POST /admin/api/fronts/publish-callback — the callback receiver.
6
+ //
7
+ // AUTH reuses the existing M2M primitive (`verifyHmac` + `X-Foundry-Signature`
8
+ // against `ctx.env.FOUNDRY_HMAC_SECRET` — a per-Worker secret string OR a Secrets
9
+ // Store binding `{ get() }`; no new auth scheme, no shared secret in code/D1),
10
+ // hardened against replay with a REQUIRED `X-Foundry-Timestamp`:
11
+ //
12
+ // X-Foundry-Timestamp: <unix seconds, within ±300s of now>
13
+ // X-Foundry-Signature: sha256=HMAC(secret, `${timestamp}\n${payload}`)
14
+ //
15
+ // where payload is the raw query string (`url.search`) for the GET poll and the
16
+ // raw body for the POST callback. Binding the signature to a fresh timestamp
17
+ // stops replay: without it a no-`window` GET would sign HMAC(secret,"") — a
18
+ // CONSTANT — so one captured URL+header pair would be a permanent bearer
19
+ // credential for the pre-publication embargo schedule.
20
+ //
21
+ // Both endpoints ship ENABLED behind FRONTS_PUBLISH_ENABLED (a rollback lever,
22
+ // default ON). Failures are loud: bad/stale HMAC → 401 + warn log; a `live`
23
+ // callback that cannot commit through the ledger → 500 + error log; disabled → 503.
24
+
25
+ import { z } from 'zod'
26
+ import { verifyHmac } from '../engine/foundry-dispatch.js'
27
+ import {
28
+ applyFrontsPublishCallback,
29
+ isFrontsPublishEnabled,
30
+ listFrontsPublishSchedule,
31
+ } from '../engine/fronts-publish.js'
32
+ import { hydrateFrontsPublishIntents } from '../engine/fronts-publish-intent.js'
33
+ import { publicUrlFor } from '../ui/screens/public-url.js'
34
+ import { type CmsRouteConfig, resolveConfig } from './config.js'
35
+ import { json, type RouteContext } from './context.js'
36
+
37
+ /**
38
+ * Resolve the Foundry HMAC secret from ctx.env.FOUNDRY_HMAC_SECRET — the SAME
39
+ * binding the existing foundry-callback route uses. Supports a plain string
40
+ * (test injection / per-Worker secret) or a Secrets Store binding `{ get() }`.
41
+ * Returns null → the route answers 500 "not configured".
42
+ */
43
+ async function resolveFoundryHmacSecret(
44
+ env: Record<string, unknown> | undefined,
45
+ ): Promise<string | null> {
46
+ const raw = env?.FOUNDRY_HMAC_SECRET
47
+ if (typeof raw === 'string') return raw || null
48
+ if (raw && typeof (raw as { get?: unknown }).get === 'function') {
49
+ try {
50
+ const val = await (raw as { get(): Promise<string> }).get()
51
+ return typeof val === 'string' && val.length > 0 ? val : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ /** Max clock skew (seconds) tolerated on X-Foundry-Timestamp — the replay window. */
60
+ const SIGNATURE_MAX_SKEW_SECONDS = 300
61
+
62
+ /** Parse the X-Foundry-Timestamp header (strict unix epoch seconds) or null. */
63
+ function parseTimestampSeconds(raw: string): number | null {
64
+ const trimmed = raw.trim()
65
+ // Strict integer seconds so the exact header bytes are what foundryd signs.
66
+ if (!/^\d{1,15}$/.test(trimmed)) return null
67
+ const seconds = Number.parseInt(trimmed, 10)
68
+ return Number.isSafeInteger(seconds) ? seconds : null
69
+ }
70
+
71
+ /**
72
+ * Verify a foundryd-signed M2M request: a fresh `X-Foundry-Timestamp` (unix
73
+ * seconds within ±{@link SIGNATURE_MAX_SKEW_SECONDS}) plus `X-Foundry-Signature`
74
+ * = HMAC over `${timestamp}\n${payload}`. Returns null when authentic, or a 401
75
+ * Response (with a loud warn log) to short-circuit the handler.
76
+ */
77
+ async function verifyFoundrySignedRequest(
78
+ request: Request,
79
+ secret: string,
80
+ payload: string,
81
+ label: string,
82
+ ): Promise<Response | null> {
83
+ const tsRaw = (request.headers.get('X-Foundry-Timestamp') ?? '').trim()
84
+ const ts = parseTimestampSeconds(tsRaw)
85
+ if (ts === null) {
86
+ console.warn(`[cms] ${label} rejected: missing or invalid X-Foundry-Timestamp`)
87
+ return json({ error: 'Missing or invalid X-Foundry-Timestamp' }, 401)
88
+ }
89
+ const now = Math.floor(Date.now() / 1_000)
90
+ if (Math.abs(now - ts) > SIGNATURE_MAX_SKEW_SECONDS) {
91
+ console.warn(`[cms] ${label} rejected: stale X-Foundry-Timestamp (skew ${now - ts}s)`)
92
+ return json({ error: 'Stale or future X-Foundry-Timestamp' }, 401)
93
+ }
94
+ const signature = request.headers.get('X-Foundry-Signature') ?? ''
95
+ const valid = await verifyHmac(secret, `${tsRaw}\n${payload}`, signature)
96
+ if (!valid) {
97
+ console.warn(`[cms] ${label} rejected: invalid X-Foundry-Signature`)
98
+ return json({ error: 'Invalid signature' }, 401)
99
+ }
100
+ return null
101
+ }
102
+
103
+ const ReceiptSchema = z.object({
104
+ published_urls: z.array(z.string()).nullish(),
105
+ platform_post_ids: z.array(z.string()).nullish(),
106
+ sha256: z.string().nullish(),
107
+ })
108
+
109
+ const CallbackSchema = z
110
+ .object({
111
+ event_id: z.string().min(1).optional(),
112
+ intent_id: z.string().min(1).nullish(),
113
+ content_id: z.string().min(1),
114
+ version: z.number().int().nullish(),
115
+ correlation_id: z.string().min(1).nullish(),
116
+ status: z.enum(['queued', 'publishing', 'live', 'late', 'failed']),
117
+ occurred_at: z.union([z.string(), z.number()]).nullish(),
118
+ receipt: ReceiptSchema.nullish(),
119
+ error: z.string().nullish(),
120
+ })
121
+ .strip()
122
+
123
+ export interface FrontsPublishRouteHandlers {
124
+ /** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
125
+ schedule(ctx: RouteContext): Promise<Response>
126
+ /** POST /fronts/publish-callback — foundryd's outbound lifecycle callback. */
127
+ publishCallback(ctx: RouteContext): Promise<Response>
128
+ }
129
+
130
+ /** Parse `window=<from>..<to>` into unix-seconds bounds; defaults to now..now+1h. */
131
+ function parseWindow(
132
+ value: string | null,
133
+ nowSeconds: number,
134
+ ): { fromSeconds: number; toSeconds: number } | { error: string } {
135
+ if (!value) return { fromSeconds: nowSeconds, toSeconds: nowSeconds + 3600 }
136
+ const parts = value.split('..')
137
+ if (parts.length !== 2) return { error: 'window must be "<from>..<to>"' }
138
+ const parseBound = (raw: string): number | null => {
139
+ const trimmed = raw.trim()
140
+ if (!trimmed) return null
141
+ if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10)
142
+ const ms = Date.parse(trimmed)
143
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1_000)
144
+ }
145
+ const fromSeconds = parseBound(parts[0])
146
+ const toSeconds = parseBound(parts[1])
147
+ if (fromSeconds === null || toSeconds === null) {
148
+ return { error: 'window bounds must be ISO-8601 timestamps or unix seconds' }
149
+ }
150
+ if (toSeconds < fromSeconds) return { error: 'window end precedes its start' }
151
+ return { fromSeconds, toSeconds }
152
+ }
153
+
154
+ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublishRouteHandlers {
155
+ const resolved = resolveConfig(config)
156
+ return {
157
+ async schedule(ctx) {
158
+ const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
159
+ if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
160
+
161
+ const url = new URL(ctx.request.url)
162
+ // The GET poll has no body; the payload is the exact query string
163
+ // foundryd sent (leading '?' included, or '' when empty), bound to the
164
+ // required timestamp so an empty-window poll can't sign a constant.
165
+ const denied = await verifyFoundrySignedRequest(
166
+ ctx.request,
167
+ hmacSecret,
168
+ url.search,
169
+ 'fronts/schedule',
170
+ )
171
+ if (denied) return denied
172
+
173
+ if (!isFrontsPublishEnabled(ctx.env)) {
174
+ return json({ error: 'fronts_publish_disabled' }, 503)
175
+ }
176
+
177
+ const now = Math.floor(Date.now() / 1_000)
178
+ const window = parseWindow(url.searchParams.get('window'), now)
179
+ if ('error' in window) return json({ error: window.error }, 400)
180
+
181
+ // `items` is the UNCHANGED 0026 legacy schedule shape — an existing
182
+ // consumer keeps working byte-for-byte. `intents` is the canonical
183
+ // publish-intent contract (packages#500) hydrated over the SAME due
184
+ // predicate, and `incomplete` names every due item that cannot form a
185
+ // complete intent, so a missing canonical field is an explicit, visible
186
+ // contract error rather than a silently short `intents` array.
187
+ const items = await listFrontsPublishSchedule(ctx.db, {
188
+ toSeconds: window.toSeconds,
189
+ nowSeconds: now,
190
+ })
191
+ const hydrated = await hydrateFrontsPublishIntents(ctx.db, {
192
+ toSeconds: window.toSeconds,
193
+ nowSeconds: now,
194
+ mediaPublicDomain: resolved.mediaPublicDomain,
195
+ // Site-RELATIVE paths: the CMS does not know the site origin, and the
196
+ // consumer joins its own trusted configured origin. A host-supplied
197
+ // origin is deliberately deferred (packages-docs/cms.md).
198
+ resolveContentUrl: (row) => publicUrlFor(row),
199
+ })
200
+ return json({
201
+ ok: true,
202
+ now,
203
+ window: { from: window.fromSeconds, to: window.toSeconds },
204
+ count: items.length,
205
+ items,
206
+ intents: hydrated.intents,
207
+ incomplete: hydrated.incomplete,
208
+ })
209
+ },
210
+
211
+ async publishCallback(ctx) {
212
+ const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
213
+ if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
214
+
215
+ let rawBody: string
216
+ try {
217
+ rawBody = await ctx.request.text()
218
+ } catch {
219
+ return json({ error: 'Failed to read request body' }, 400)
220
+ }
221
+
222
+ const denied = await verifyFoundrySignedRequest(
223
+ ctx.request,
224
+ hmacSecret,
225
+ rawBody,
226
+ 'fronts/publish-callback',
227
+ )
228
+ if (denied) return denied
229
+
230
+ if (!isFrontsPublishEnabled(ctx.env)) {
231
+ return json({ error: 'fronts_publish_disabled' }, 503)
232
+ }
233
+
234
+ let raw: unknown
235
+ try {
236
+ raw = JSON.parse(rawBody)
237
+ } catch {
238
+ return json({ error: 'Invalid JSON body' }, 400)
239
+ }
240
+ const parsed = CallbackSchema.safeParse(raw)
241
+ if (!parsed.success) {
242
+ return json({ error: 'Invalid payload', details: parsed.error.flatten() }, 400)
243
+ }
244
+
245
+ // Idempotency key: body event_id, else the Idempotency-Key header.
246
+ const eventId = parsed.data.event_id ?? ctx.request.headers.get('Idempotency-Key') ?? null
247
+ if (!eventId) {
248
+ return json({ error: 'Missing event_id / Idempotency-Key' }, 400)
249
+ }
250
+
251
+ const result = await applyFrontsPublishCallback(ctx.db, {
252
+ eventId,
253
+ intentId: parsed.data.intent_id ?? null,
254
+ contentId: parsed.data.content_id,
255
+ version: parsed.data.version ?? null,
256
+ correlationId: parsed.data.correlation_id ?? null,
257
+ status: parsed.data.status,
258
+ occurredAt: parsed.data.occurred_at ?? null,
259
+ receipt: parsed.data.receipt ?? null,
260
+ error: parsed.data.error ?? null,
261
+ })
262
+
263
+ if (!result.contentFound) {
264
+ return json({ error: 'content_not_found', contentId: parsed.data.content_id }, 404)
265
+ }
266
+ if (result.ledgerError) {
267
+ // A confirmed `live` that could not be committed through the ledger is a
268
+ // hard, loud failure — never a silent zero.
269
+ console.error(
270
+ `[cms] fronts/publish-callback ledger commit failed for content ${parsed.data.content_id}: ${result.ledgerError}`,
271
+ )
272
+ return json(
273
+ {
274
+ ok: false,
275
+ error: 'ledger_publish_failed',
276
+ detail: result.ledgerError,
277
+ status: result.status,
278
+ },
279
+ 500,
280
+ )
281
+ }
282
+
283
+ return json({
284
+ ok: true,
285
+ deduped: result.deduped,
286
+ status: result.status,
287
+ published: result.published,
288
+ attemptId: result.attemptId,
289
+ })
290
+ },
291
+ }
292
+ }
@@ -100,6 +100,11 @@ export {
100
100
  type DashboardRouteConfig,
101
101
  type DashboardRouteHandlers,
102
102
  } from './dashboard.js'
103
+ // Fronts publish surface — the HMAC schedule poll + publish-callback receiver.
104
+ export {
105
+ createFrontsPublishRoutes,
106
+ type FrontsPublishRouteHandlers,
107
+ } from './fronts-publish.js'
103
108
  // Import routes — two-step parse→confirm pipeline.
104
109
  export { createImportRoutes, type ImportRouteHandlers } from './imports.js'
105
110
  // Media routes.
@@ -198,6 +203,7 @@ import {
198
203
  } from './content-insights.js'
199
204
  import { type CronRouteHandlers, createCronRoutes } from './cron.js'
200
205
  import { createDashboardRoutes, type DashboardRouteHandlers } from './dashboard.js'
206
+ import { createFrontsPublishRoutes, type FrontsPublishRouteHandlers } from './fronts-publish.js'
201
207
  import { createImportRoutes, type ImportRouteHandlers } from './imports.js'
202
208
  import { createMediaRoutes, type MediaRouteHandlers } from './media.js'
203
209
  import { createPreviewRoutes, type PreviewRouteHandlers } from './preview.js'
@@ -231,6 +237,7 @@ export interface CmsRoutes {
231
237
  apiKeys: ApiKeyRouteHandlers
232
238
  webhooks: WebhookRouteHandlers
233
239
  contentInsights: ContentInsightsRouteHandlers
240
+ frontsPublish: FrontsPublishRouteHandlers
234
241
  }
235
242
 
236
243
  /**
@@ -297,5 +304,6 @@ export function createCmsRoutes(
297
304
  authz: config.authz,
298
305
  activeWorkspaceId: config.activeWorkspaceId,
299
306
  }),
307
+ frontsPublish: createFrontsPublishRoutes(base),
300
308
  }
301
309
  }