@growth-labs/cms 0.5.30 → 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 (67) 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 +2 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +5 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publisher.d.ts.map +1 -1
  18. package/dist/engine/publisher.js +21 -1
  19. package/dist/engine/publisher.js.map +1 -1
  20. package/dist/integration/index.d.ts.map +1 -1
  21. package/dist/integration/index.js +10 -0
  22. package/dist/integration/index.js.map +1 -1
  23. package/dist/routes/fronts-publish.d.ts +10 -0
  24. package/dist/routes/fronts-publish.d.ts.map +1 -0
  25. package/dist/routes/fronts-publish.js +250 -0
  26. package/dist/routes/fronts-publish.js.map +1 -0
  27. package/dist/routes/index.d.ts +3 -0
  28. package/dist/routes/index.d.ts.map +1 -1
  29. package/dist/routes/index.js +4 -0
  30. package/dist/routes/index.js.map +1 -1
  31. package/dist/schema/layout.d.ts +2 -2
  32. package/dist/schema/migrations.d.ts.map +1 -1
  33. package/dist/schema/migrations.js +36 -0
  34. package/dist/schema/migrations.js.map +1 -1
  35. package/dist/schema/portable-text.d.ts +2 -2
  36. package/dist/schema/tables.d.ts +1 -1
  37. package/dist/schema/tables.d.ts.map +1 -1
  38. package/dist/schema/tables.js +2 -0
  39. package/dist/schema/tables.js.map +1 -1
  40. package/dist/schema/types.d.ts +59 -0
  41. package/dist/schema/types.d.ts.map +1 -1
  42. package/dist/schema/types.js.map +1 -1
  43. package/dist/surveys/schema.d.ts +54 -54
  44. package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
  45. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
  46. package/dist/ui/api/fronts/publish-callback.js +8 -0
  47. package/dist/ui/api/fronts/publish-callback.js.map +1 -0
  48. package/dist/ui/api/fronts/schedule.d.ts +3 -0
  49. package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
  50. package/dist/ui/api/fronts/schedule.js +8 -0
  51. package/dist/ui/api/fronts/schedule.js.map +1 -0
  52. package/migrations/0026_fronts_publish.sql +31 -0
  53. package/migrations/0027_fronts_publish_intent.sql +44 -0
  54. package/package.json +1 -1
  55. package/src/engine/foundry-dispatch.ts +22 -6
  56. package/src/engine/fronts-publish-intent.ts +676 -0
  57. package/src/engine/fronts-publish.ts +417 -0
  58. package/src/engine/index.ts +27 -0
  59. package/src/engine/publisher.ts +21 -1
  60. package/src/integration/index.ts +10 -0
  61. package/src/routes/fronts-publish.ts +292 -0
  62. package/src/routes/index.ts +8 -0
  63. package/src/schema/migrations.ts +36 -0
  64. package/src/schema/tables.ts +2 -0
  65. package/src/schema/types.ts +61 -0
  66. package/src/ui/api/fronts/publish-callback.ts +18 -0
  67. package/src/ui/api/fronts/schedule.ts +18 -0
@@ -0,0 +1,417 @@
1
+ // Fronts publish path — the additive, flag-gated, out-of-process publish flow
2
+ // (increment: cms/Masthead side).
3
+ //
4
+ // TWO seams, both driven by the external Fronts publish worker (foundryd):
5
+ //
6
+ // 1. listFrontsPublishSchedule — the poll surface. Returns `scheduled` items
7
+ // whose `publish_at` has arrived or is arriving within the caller's window
8
+ // (plus any overdue), so foundryd knows what to publish. Mirrors the
9
+ // `runScheduledPublish` (engine/cron.ts) due-predicate — `content_items`
10
+ // scheduling columns are INTEGER unix seconds, so the window is compared in
11
+ // seconds (NOT the D1 `datetime('now')` TEXT format; there is no
12
+ // space-vs-T comparison trap on this column).
13
+ //
14
+ // 2. applyFrontsPublishCallback — the receiver. Records foundryd's outbound
15
+ // callback (idempotent by `event_id`), reflects the lifecycle status onto
16
+ // `content_items.fronts_publish_status`, and — ONLY on a confirmed `live`
17
+ // with a receipt — drives the item to published THROUGH the existing
18
+ // `content_publication_attempts` 2PC ledger via `publishOne`. A
19
+ // `queued`/`publishing`/`late`/`failed` callback (or a `live` with no
20
+ // receipt) never flips the item to published.
21
+ //
22
+ // The legacy D1 cron publish path (engine/cron.ts `runScheduledPublish` + the
23
+ // site's cron) is UNTOUCHED and remains the rollback lever; this path is purely
24
+ // additive and gated by FRONTS_PUBLISH_ENABLED (default ON — a rollback lever,
25
+ // not a dark-staging gate).
26
+
27
+ import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
28
+ import type { D1Database } from './d1.js'
29
+ import { type PublicationReconciler, publishOne } from './publication.js'
30
+
31
+ /** The five lifecycle states foundryd reports for a Fronts publish intent. */
32
+ export const FRONTS_PUBLISH_STATUSES: readonly FrontsPublishStatus[] = [
33
+ 'queued',
34
+ 'publishing',
35
+ 'live',
36
+ 'late',
37
+ 'failed',
38
+ ] as const
39
+
40
+ export function isFrontsPublishStatus(value: unknown): value is FrontsPublishStatus {
41
+ return typeof value === 'string' && (FRONTS_PUBLISH_STATUSES as string[]).includes(value)
42
+ }
43
+
44
+ /**
45
+ * Feature flag — a ROLLBACK LEVER, not a graduation gate. Default ENABLED; the
46
+ * additive Fronts publish endpoints only go dark when the host explicitly sets
47
+ * FRONTS_PUBLISH_ENABLED to a falsy string ('0' | 'false' | 'off' | 'no') or the
48
+ * boolean false. When off, foundryd's polls/callbacks are refused loudly (503)
49
+ * and the untouched D1 cron path owns publishing.
50
+ */
51
+ export function isFrontsPublishEnabled(env: Record<string, unknown> | undefined): boolean {
52
+ const raw = env?.FRONTS_PUBLISH_ENABLED
53
+ if (raw === undefined || raw === null) return true
54
+ if (typeof raw === 'boolean') return raw
55
+ if (typeof raw === 'number') return raw !== 0
56
+ if (typeof raw === 'string') {
57
+ return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase())
58
+ }
59
+ return true
60
+ }
61
+
62
+ // --- 1. Schedule poll ------------------------------------------------------
63
+
64
+ export interface FrontsScheduleItem {
65
+ id: string
66
+ type: ContentType
67
+ slug: string
68
+ title: string
69
+ channel: string | null
70
+ /** Scheduled publish time, unix epoch seconds. */
71
+ publishAt: number
72
+ /** Current lifecycle marker (null until foundryd first acknowledges it). */
73
+ frontsPublishStatus: FrontsPublishStatus | null
74
+ /** publish_at is at or before `now` — foundryd is late on this one. */
75
+ overdue: boolean
76
+ }
77
+
78
+ export interface ListFrontsPublishScheduleOptions {
79
+ /** Window upper bound, unix epoch seconds. Items due at/before this are returned. */
80
+ toSeconds: number
81
+ /** Reference time for the `overdue` flag, unix epoch seconds. Defaults to Date.now(). */
82
+ nowSeconds?: number
83
+ /** Hard cap on rows returned. Default 200, max 1000. */
84
+ limit?: number
85
+ }
86
+
87
+ /**
88
+ * List `scheduled` items due at or before the window end. Includes overdue
89
+ * items (publish_at < window start) — the window's lower bound is intentionally
90
+ * not applied, matching foundryd's "next window + any overdue" contract. Ordered
91
+ * by publish_at ascending so the most-overdue item is published first.
92
+ */
93
+ export async function listFrontsPublishSchedule(
94
+ db: D1Database,
95
+ options: ListFrontsPublishScheduleOptions,
96
+ ): Promise<FrontsScheduleItem[]> {
97
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1_000)
98
+ const limit = Math.min(Math.max(1, Math.floor(options.limit ?? 200)), 1000)
99
+ const result = await db
100
+ .prepare(
101
+ `SELECT id, type, slug, title, channel, publish_at, fronts_publish_status
102
+ FROM content_items
103
+ WHERE status = 'scheduled'
104
+ AND publish_at IS NOT NULL
105
+ AND publish_at <= ?
106
+ ORDER BY publish_at ASC
107
+ LIMIT ?`,
108
+ )
109
+ .bind(options.toSeconds, limit)
110
+ .all<{
111
+ id: string
112
+ type: ContentType
113
+ slug: string
114
+ title: string
115
+ channel: string | null
116
+ publish_at: number
117
+ fronts_publish_status: FrontsPublishStatus | null
118
+ }>()
119
+ return (result.results ?? []).map((row) => ({
120
+ id: row.id,
121
+ type: row.type,
122
+ slug: row.slug,
123
+ title: row.title,
124
+ channel: row.channel,
125
+ publishAt: row.publish_at,
126
+ frontsPublishStatus: row.fronts_publish_status,
127
+ overdue: row.publish_at <= now,
128
+ }))
129
+ }
130
+
131
+ // --- 2. Publish callback receiver -----------------------------------------
132
+
133
+ export interface FrontsPublishReceipt {
134
+ published_urls?: string[] | null
135
+ platform_post_ids?: string[] | null
136
+ sha256?: string | null
137
+ }
138
+
139
+ export interface FrontsPublishCallbackInput {
140
+ eventId: string
141
+ intentId?: string | null
142
+ contentId: string
143
+ version?: number | null
144
+ correlationId?: string | null
145
+ status: FrontsPublishStatus
146
+ /** ISO-8601 string or unix epoch seconds; used as the published_at on `live`. */
147
+ occurredAt?: string | number | null
148
+ receipt?: FrontsPublishReceipt | null
149
+ error?: string | null
150
+ /** Reference time (unix seconds), for tests. Defaults to Date.now(). */
151
+ now?: number
152
+ }
153
+
154
+ export interface FrontsPublishCallbackResult {
155
+ ok: boolean
156
+ /** True when this event_id was already processed — no state change this call. */
157
+ deduped: boolean
158
+ status: FrontsPublishStatus
159
+ /** True when the item is published (flipped by this call, or already was). */
160
+ published: boolean
161
+ /** The content_publication_attempts ledger row id, when this call published. */
162
+ attemptId: string | null
163
+ contentFound: boolean
164
+ /** Set when a `live` callback could not be committed through the ledger. */
165
+ ledgerError: string | null
166
+ }
167
+
168
+ function hasReceipt(receipt: FrontsPublishReceipt | null | undefined): boolean {
169
+ if (!receipt) return false
170
+ const urls = Array.isArray(receipt.published_urls) && receipt.published_urls.length > 0
171
+ const ids = Array.isArray(receipt.platform_post_ids) && receipt.platform_post_ids.length > 0
172
+ const sha = typeof receipt.sha256 === 'string' && receipt.sha256.trim().length > 0
173
+ return urls || ids || sha
174
+ }
175
+
176
+ /** Resolve occurred_at to a bounded, non-future unix-seconds value (or null). */
177
+ function resolveOccurredAtSeconds(
178
+ occurredAt: string | number | null | undefined,
179
+ now: number,
180
+ ): number | null {
181
+ let seconds: number | null = null
182
+ if (typeof occurredAt === 'number' && Number.isFinite(occurredAt)) {
183
+ seconds = Math.floor(occurredAt)
184
+ } else if (typeof occurredAt === 'string' && occurredAt.trim()) {
185
+ const ms = Date.parse(occurredAt)
186
+ if (!Number.isNaN(ms)) seconds = Math.floor(ms / 1_000)
187
+ }
188
+ if (seconds === null || !Number.isSafeInteger(seconds) || seconds < 0) return null
189
+ // publishOne rejects a published_at later than `now`; clamp for clock skew.
190
+ return Math.min(seconds, now)
191
+ }
192
+
193
+ /**
194
+ * Apply one foundryd publish-callback. Idempotent by `event_id`: a repeat
195
+ * delivery returns the stored outcome without re-applying. See the module
196
+ * header for the state-transition contract.
197
+ */
198
+ export async function applyFrontsPublishCallback(
199
+ db: D1Database,
200
+ input: FrontsPublishCallbackInput,
201
+ ): Promise<FrontsPublishCallbackResult> {
202
+ const now = input.now ?? Math.floor(Date.now() / 1_000)
203
+
204
+ // 1. Idempotency — replay the stored outcome for a known event_id.
205
+ const existing = await readStoredOutcome(db, input.eventId)
206
+ if (existing) return existing
207
+
208
+ // 2. Resolve the target item. A missing item is NOT recorded, so a retry can
209
+ // still land once the item exists.
210
+ const item = await db
211
+ .prepare('SELECT id, status, fronts_publish_status FROM content_items WHERE id = ? LIMIT 1')
212
+ .bind(input.contentId)
213
+ .first<{ id: string; status: string; fronts_publish_status: FrontsPublishStatus | null }>()
214
+ if (!item) {
215
+ return {
216
+ ok: false,
217
+ deduped: false,
218
+ status: input.status,
219
+ published: false,
220
+ attemptId: null,
221
+ contentFound: false,
222
+ ledgerError: null,
223
+ }
224
+ }
225
+
226
+ // 3. On a confirmed `live` WITH a receipt, drive the publish through the 2PC
227
+ // ledger — record attempt + commit — flipping the item to published.
228
+ // A `live` on an already-published item just confirms it (no re-publish,
229
+ // so we never race the cron path into a duplicate revision).
230
+ let published = item.status === 'published'
231
+ let attemptId: string | null = null
232
+ let ledgerError: string | null = null
233
+ const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
234
+ if (shouldPublish && item.status !== 'published') {
235
+ try {
236
+ const receiptSummary = summarizeReceipt(input.receipt)
237
+ const surface: PublicationReconciler = {
238
+ name: 'fronts',
239
+ async prepare() {
240
+ return { name: 'fronts', status: 'ok', phase: 'prepare' }
241
+ },
242
+ async commit() {
243
+ return { name: 'fronts', status: 'ok', phase: 'commit', message: receiptSummary }
244
+ },
245
+ }
246
+ const publishedAt = resolveOccurredAtSeconds(input.occurredAt, now)
247
+ const receipt = await publishOne(db, {
248
+ contentId: input.contentId,
249
+ now,
250
+ ...(publishedAt !== null ? { publishedAt } : {}),
251
+ createdBy: 'foundry.fronts-publish',
252
+ surfaces: [surface],
253
+ })
254
+ attemptId = receipt.attemptId
255
+ published = true
256
+ } catch (error) {
257
+ // publishOne can throw when a concurrent/superseding publish already
258
+ // moved the canonical pointer (the item ends up published anyway) — treat
259
+ // that as success. Any other failure is recorded and surfaced loudly.
260
+ const reread = await db
261
+ .prepare('SELECT status FROM content_items WHERE id = ? LIMIT 1')
262
+ .bind(input.contentId)
263
+ .first<{ status: string }>()
264
+ if (reread?.status === 'published') {
265
+ published = true
266
+ } else {
267
+ ledgerError = error instanceof Error ? error.message : String(error)
268
+ }
269
+ }
270
+ }
271
+
272
+ const incomingOccurredAt = resolveOccurredAtSeconds(input.occurredAt, now)
273
+
274
+ // 4. Reflect the lifecycle marker onto the item — but NEVER regress it. A
275
+ // retried/out-of-order `publishing`/`failed` arriving after `live` must not
276
+ // make a published item read as publishing/failed, so gate on occurred_at
277
+ // ordering and the terminal `live`/published state.
278
+ const latestSeenRow = await db
279
+ .prepare(
280
+ 'SELECT MAX(occurred_at) AS latest FROM fronts_publish_events WHERE content_id = ? AND occurred_at IS NOT NULL',
281
+ )
282
+ .bind(input.contentId)
283
+ .first<{ latest: number | null }>()
284
+ if (
285
+ shouldApplyFrontsMarker(
286
+ item.status,
287
+ item.fronts_publish_status,
288
+ input.status,
289
+ incomingOccurredAt,
290
+ latestSeenRow?.latest ?? null,
291
+ )
292
+ ) {
293
+ await db
294
+ .prepare(
295
+ 'UPDATE content_items SET fronts_publish_status = ?, updated_at = unixepoch() WHERE id = ?',
296
+ )
297
+ .bind(input.status, input.contentId)
298
+ .run()
299
+ }
300
+
301
+ // 5. Record the durable, idempotent event row (the "processed" marker). The
302
+ // dedupe SELECT (step 1) and this INSERT are not one transaction, so two
303
+ // concurrent same-event_id deliveries can both reach here — ON CONFLICT DO
304
+ // NOTHING makes the loser a no-op (never a spurious PK-violation 500), and
305
+ // we then replay the winner's stored outcome. publishOne's own CAS already
306
+ // prevents a double publish.
307
+ const insertResult = await db
308
+ .prepare(
309
+ `INSERT INTO fronts_publish_events
310
+ (event_id, intent_id, content_id, version, correlation_id, status, occurred_at,
311
+ receipt_json, error, published, attempt_id, received_at)
312
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
313
+ ON CONFLICT(event_id) DO NOTHING`,
314
+ )
315
+ .bind(
316
+ input.eventId,
317
+ input.intentId ?? null,
318
+ input.contentId,
319
+ typeof input.version === 'number' && Number.isFinite(input.version)
320
+ ? Math.floor(input.version)
321
+ : null,
322
+ input.correlationId ?? null,
323
+ input.status,
324
+ incomingOccurredAt,
325
+ input.receipt ? JSON.stringify(input.receipt) : null,
326
+ ledgerError,
327
+ published ? 1 : 0,
328
+ attemptId,
329
+ now,
330
+ )
331
+ .run()
332
+
333
+ const changes = insertResult.meta?.changes
334
+ if (typeof changes === 'number' && changes === 0) {
335
+ // A concurrent callback with the same event_id won the PK race.
336
+ const winner = await readStoredOutcome(db, input.eventId)
337
+ if (winner) return winner
338
+ }
339
+
340
+ return {
341
+ ok: ledgerError === null,
342
+ deduped: false,
343
+ status: input.status,
344
+ published,
345
+ attemptId,
346
+ contentFound: true,
347
+ ledgerError,
348
+ }
349
+ }
350
+
351
+ /** Replay a stored callback outcome by event_id, or null when not yet recorded. */
352
+ async function readStoredOutcome(
353
+ db: D1Database,
354
+ eventId: string,
355
+ ): Promise<FrontsPublishCallbackResult | null> {
356
+ const row = await db
357
+ .prepare(
358
+ 'SELECT status, published, attempt_id, error FROM fronts_publish_events WHERE event_id = ? LIMIT 1',
359
+ )
360
+ .bind(eventId)
361
+ .first<{
362
+ status: FrontsPublishStatus
363
+ published: number
364
+ attempt_id: string | null
365
+ error: string | null
366
+ }>()
367
+ if (!row) return null
368
+ return {
369
+ ok: !row.error,
370
+ deduped: true,
371
+ status: row.status,
372
+ published: row.published === 1,
373
+ attemptId: row.attempt_id,
374
+ contentFound: true,
375
+ ledgerError: row.error,
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Whether an incoming callback may overwrite `content_items.fronts_publish_status`.
381
+ * `live` is the authoritative terminal-success marker (always reflected, never
382
+ * downgraded); older-by-occurred_at callbacks may not regress a newer marker.
383
+ */
384
+ function shouldApplyFrontsMarker(
385
+ itemStatus: string,
386
+ currentMarker: FrontsPublishStatus | null,
387
+ incomingStatus: FrontsPublishStatus,
388
+ incomingOccurredAt: number | null,
389
+ latestSeenOccurredAt: number | null,
390
+ ): boolean {
391
+ if (incomingStatus === 'live') return true
392
+ if (itemStatus === 'published' || currentMarker === 'live') return false
393
+ if (
394
+ incomingOccurredAt !== null &&
395
+ latestSeenOccurredAt !== null &&
396
+ incomingOccurredAt < latestSeenOccurredAt
397
+ ) {
398
+ return false
399
+ }
400
+ return true
401
+ }
402
+
403
+ /** Bounded, human-readable receipt digest recorded on the ledger surface receipt. */
404
+ function summarizeReceipt(receipt: FrontsPublishReceipt | null | undefined): string {
405
+ if (!receipt) return 'fronts:live'
406
+ const parts: string[] = []
407
+ if (Array.isArray(receipt.published_urls) && receipt.published_urls.length > 0) {
408
+ parts.push(`urls=${receipt.published_urls.length}`)
409
+ }
410
+ if (Array.isArray(receipt.platform_post_ids) && receipt.platform_post_ids.length > 0) {
411
+ parts.push(`posts=${receipt.platform_post_ids.length}`)
412
+ }
413
+ if (typeof receipt.sha256 === 'string' && receipt.sha256.trim()) {
414
+ parts.push(`sha256=${receipt.sha256.trim().slice(0, 64)}`)
415
+ }
416
+ return `fronts:live ${parts.join(' ')}`.trim().slice(0, 500)
417
+ }
@@ -83,6 +83,33 @@ export {
83
83
  type ProcessingState,
84
84
  verifyHmac,
85
85
  } from './foundry-dispatch.js'
86
+ // Fronts publish path — schedule poll + publish-callback receiver (additive, flag-gated).
87
+ export {
88
+ applyFrontsPublishCallback,
89
+ FRONTS_PUBLISH_STATUSES,
90
+ type FrontsPublishCallbackInput,
91
+ type FrontsPublishCallbackResult,
92
+ type FrontsPublishReceipt,
93
+ type FrontsScheduleItem,
94
+ isFrontsPublishEnabled,
95
+ isFrontsPublishStatus,
96
+ type ListFrontsPublishScheduleOptions,
97
+ listFrontsPublishSchedule,
98
+ } from './fronts-publish.js'
99
+ // Canonical Fronts publish INTENT contract — the CMS-owned canonical values a
100
+ // pull consumer needs to build a complete publish intent (packages#500).
101
+ export {
102
+ armFrontsPublishTriggerToken,
103
+ type FrontsPublishContentRefs,
104
+ type FrontsPublishIntent,
105
+ FrontsPublishIntentError,
106
+ type FrontsPublishIntentErrorCode,
107
+ type FrontsPublishIntentFailure,
108
+ frontsPublishTriggerTokenHash,
109
+ type HydrateFrontsPublishIntentsResult,
110
+ hydrateFrontsPublishIntents,
111
+ setFrontsChannelTargets,
112
+ } from './fronts-publish-intent.js'
86
113
  // Import parsers — pure string→struct transforms for Markdown/Ghost/Substack.
87
114
  export {
88
115
  htmlToMarkdown,
@@ -22,6 +22,7 @@
22
22
  import type { ContentType } from '../schema/types.js'
23
23
  import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
24
24
  import type { D1Database } from './d1.js'
25
+ import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
25
26
  import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
26
27
  import { slugify } from './slug.js'
27
28
  import {
@@ -948,7 +949,13 @@ export async function updateContentItem(
948
949
  ai_locked_fields = ?,
949
950
  seo_focus_keyword = ?,
950
951
  metadata_json = ?,
951
- updated_at = unixepoch()
952
+ updated_at = unixepoch(),
953
+ -- The canonical revision counter behind the Fronts publish intent id
954
+ -- masthead:<content_id>:<version> (migration 0027). This is the ONE seam
955
+ -- that advances it, so an intent identity changes exactly when the item's
956
+ -- canonical content does -- and never on a fronts_publish_status marker
957
+ -- write or a schedule poll.
958
+ canonical_version = canonical_version + 1
952
959
  WHERE id = ?
953
960
  `,
954
961
  )
@@ -1172,6 +1179,12 @@ export async function scheduleContent(
1172
1179
  )
1173
1180
  .bind(publishAt, publishTz, id)
1174
1181
  .run()
1182
+ // Arming an item for the additive Fronts publish path happens HERE — the one
1183
+ // seam through which an item becomes `scheduled` with a `publish_at`. The mint
1184
+ // is compare-and-set and never rotates an existing token, so an item's
1185
+ // `trigger_token_hash` is stable across reschedules and across every poll.
1186
+ // The raw token stays in D1; only `sha256:<hex>` is ever exposed.
1187
+ await armFrontsPublishTriggerToken(db, id)
1175
1188
  }
1176
1189
 
1177
1190
  export async function evaluateContentBodyForPublish(
@@ -1304,6 +1317,13 @@ export async function duplicateContentItem(
1304
1317
  // — a duplicate should keep the SEO work already done on the source. But
1305
1318
  // board_position is the source item's slot in the editorial board ordering;
1306
1319
  // a fresh draft copy must NOT inherit it, so it is reset to NULL.
1320
+ //
1321
+ // The migration-0027 Fronts publish columns are DELIBERATELY omitted so the
1322
+ // copy takes their defaults: canonical_version = 1 (a new item starts its own
1323
+ // revision line), fronts_channel_targets = NULL and
1324
+ // fronts_publish_trigger_token = NULL (a distinct item must be armed on its
1325
+ // own and must never inherit another item's publish trigger token). Do not
1326
+ // add them to the column list.
1307
1327
  await db
1308
1328
  .prepare(
1309
1329
  `INSERT INTO content_items (
@@ -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`,