@growth-labs/cms 0.6.5 → 0.6.7

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 (75) hide show
  1. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  2. package/dist/engine/foundry-dispatch.js +36 -7
  3. package/dist/engine/foundry-dispatch.js.map +1 -1
  4. package/dist/engine/fronts-publish-intent.d.ts +15 -4
  5. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  6. package/dist/engine/fronts-publish-intent.js +52 -19
  7. package/dist/engine/fronts-publish-intent.js.map +1 -1
  8. package/dist/engine/fronts-publish.d.ts +19 -0
  9. package/dist/engine/fronts-publish.d.ts.map +1 -1
  10. package/dist/engine/fronts-publish.js +158 -5
  11. package/dist/engine/fronts-publish.js.map +1 -1
  12. package/dist/engine/index.d.ts +1 -0
  13. package/dist/engine/index.d.ts.map +1 -1
  14. package/dist/engine/index.js +1 -0
  15. package/dist/engine/index.js.map +1 -1
  16. package/dist/engine/podcast-source.d.ts +28 -0
  17. package/dist/engine/podcast-source.d.ts.map +1 -0
  18. package/dist/engine/podcast-source.js +50 -0
  19. package/dist/engine/podcast-source.js.map +1 -0
  20. package/dist/engine/publication-current-guard.d.ts +10 -0
  21. package/dist/engine/publication-current-guard.d.ts.map +1 -0
  22. package/dist/engine/publication-current-guard.js +76 -0
  23. package/dist/engine/publication-current-guard.js.map +1 -0
  24. package/dist/engine/publication.d.ts +4 -0
  25. package/dist/engine/publication.d.ts.map +1 -1
  26. package/dist/engine/publication.js +13 -0
  27. package/dist/engine/publication.js.map +1 -1
  28. package/dist/engine/publisher.d.ts.map +1 -1
  29. package/dist/engine/publisher.js +20 -13
  30. package/dist/engine/publisher.js.map +1 -1
  31. package/dist/engine/soft-delete.js +2 -2
  32. package/dist/engine/soft-delete.js.map +1 -1
  33. package/dist/providers/types.d.ts +9 -3
  34. package/dist/providers/types.d.ts.map +1 -1
  35. package/dist/providers/types.js.map +1 -1
  36. package/dist/routes/content.d.ts +2 -0
  37. package/dist/routes/content.d.ts.map +1 -1
  38. package/dist/routes/content.js +4 -2
  39. package/dist/routes/content.js.map +1 -1
  40. package/dist/routes/fronts-publish.d.ts +2 -2
  41. package/dist/routes/fronts-publish.d.ts.map +1 -1
  42. package/dist/routes/fronts-publish.js +52 -23
  43. package/dist/routes/fronts-publish.js.map +1 -1
  44. package/dist/routes/index.js +1 -1
  45. package/dist/routes/index.js.map +1 -1
  46. package/dist/schema/layout.d.ts +2 -2
  47. package/dist/ui/api/_content-config.d.ts.map +1 -1
  48. package/dist/ui/api/_content-config.js +1 -0
  49. package/dist/ui/api/_content-config.js.map +1 -1
  50. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
  51. package/dist/ui/api/fronts/publish-callback.js +2 -6
  52. package/dist/ui/api/fronts/publish-callback.js.map +1 -1
  53. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  54. package/dist/ui/api/fronts/schedule.js +2 -6
  55. package/dist/ui/api/fronts/schedule.js.map +1 -1
  56. package/dist/ui/inspector/PublishTab.js +1 -1
  57. package/dist/ui/inspector/PublishTab.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/engine/foundry-dispatch.ts +67 -12
  60. package/src/engine/fronts-publish-intent.ts +78 -26
  61. package/src/engine/fronts-publish.ts +223 -5
  62. package/src/engine/index.ts +4 -0
  63. package/src/engine/podcast-source.ts +82 -0
  64. package/src/engine/publication-current-guard.ts +87 -0
  65. package/src/engine/publication.ts +23 -0
  66. package/src/engine/publisher.ts +21 -13
  67. package/src/engine/soft-delete.ts +2 -2
  68. package/src/providers/types.ts +6 -1
  69. package/src/routes/content.ts +6 -2
  70. package/src/routes/fronts-publish.ts +63 -25
  71. package/src/routes/index.ts +1 -1
  72. package/src/ui/api/_content-config.ts +2 -0
  73. package/src/ui/api/fronts/publish-callback.ts +2 -8
  74. package/src/ui/api/fronts/schedule.ts +2 -8
  75. package/src/ui/inspector/PublishTab.tsx +2 -2
@@ -19,8 +19,9 @@
19
19
  // * ALL times are unix epoch SECONDS — the same column and units the legacy
20
20
  // cron predicate uses, so there is no D1 `datetime('now')` TEXT comparison
21
21
  // on `publish_at` and no space-vs-`T` skew trap.
22
- // * `version` is `content_items.canonical_version`, advanced by exactly one
23
- // seam (updateContentItem) — NOT by a fronts_publish_status marker write and
22
+ // * `version` is `content_items.canonical_version`, advanced by canonical edits
23
+ // and actual delete/restore/archive transitions — NOT by scheduling or a
24
+ // fronts_publish_status marker write and
24
25
  // NOT by polling, so repeated reads are identity-stable while a real
25
26
  // canonical revision does change identity.
26
27
  // * `trigger_token_hash` is `sha256:<hex>` of
@@ -28,7 +29,7 @@
28
29
  // rest exactly like `video_content.processing_trigger_token` (migration
29
30
  // 0006) and NEVER leaves this package.
30
31
  // * Absence is explicit per media type: an article needs a body, a video needs
31
- // a READY HLS manifest, a podcast needs an audio object; `images` and
32
+ // a READY HLS manifest, a podcast needs audio and its transcript; `images` and
32
33
  // `links` are always optionally empty. Anything required and missing is a
33
34
  // `FrontsPublishIntentError`, never an empty default.
34
35
  // * KNOWN GAP, reported not papered over: the wire field is
@@ -39,6 +40,12 @@
39
40
  // other. Real audio HLS is a separate media-pipeline change.
40
41
  import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
41
42
  import type { D1Database } from './d1.js'
43
+ import {
44
+ frontsPodcastSourceGeneration,
45
+ type PodcastPublishBlockerCode,
46
+ podcastPublishBlocker,
47
+ validPodcastSourceUrl,
48
+ } from './podcast-source.js'
42
49
 
43
50
  /** The CMS's own name for the record this intent describes. */
44
51
  export const FRONTS_PUBLISH_INTENT_SOURCE = 'masthead'
@@ -62,6 +69,7 @@ export type FrontsPublishIntentErrorCode =
62
69
  | 'missing_article_body'
63
70
  | 'video_media_not_ready'
64
71
  | 'missing_podcast_audio'
72
+ | PodcastPublishBlockerCode
65
73
 
66
74
  /**
67
75
  * A due item whose canonical data cannot produce a complete intent. Thrown by
@@ -122,12 +130,16 @@ export interface FrontsPublishPodcastAudioRef {
122
130
  * dispatch time" pattern `video_content.processing_source_url` already uses.
123
131
  * `kind` is always `'https'` today: a single literal, not an open enum,
124
132
  * because there is exactly one supported source kind. Present only while the
125
- * podcast has a source but no audio yet; once `podcast_audio_hls` exists this
126
- * is null, so a consumer never sees both at once.
133
+ * podcast is incomplete. An existing audio object with a missing transcript
134
+ * still needs production; it is not a publishable audio reference yet.
127
135
  */
128
136
  export interface FrontsPublishPodcastSourceRef {
129
137
  kind: 'https'
130
138
  url: string
139
+ /** Immutable media identity; rescheduling alone does not change it. */
140
+ generation: string
141
+ /** Positive finite duration measured for the current captured source. */
142
+ duration_seconds: number
131
143
  }
132
144
 
133
145
  export interface FrontsPublishLinkRef {
@@ -141,7 +153,7 @@ export interface FrontsPublishContentRefs {
141
153
  images: FrontsPublishImageRef[]
142
154
  video_hls: FrontsPublishVideoHlsRef | null
143
155
  podcast_audio_hls: FrontsPublishPodcastAudioRef | null
144
- /** Null once podcast_audio_hls exists, or if no source was ever captured. */
156
+ /** Present only while media is incomplete and a valid source was captured. */
145
157
  podcast_source: FrontsPublishPodcastSourceRef | null
146
158
  /** `[]` is valid. */
147
159
  links: FrontsPublishLinkRef[]
@@ -328,6 +340,8 @@ export interface HydrateFrontsPublishIntentsOptions {
328
340
  resolveContentUrl: (row: { type: ContentType; slug: string }) => string
329
341
  /** Public media host used for media rows with no stored URL. Default 'media.fronts.co'. */
330
342
  mediaPublicDomain?: string
343
+ /** False keeps source-only readiness failures visible without requesting production. */
344
+ podcastSourceProductionEnabled?: boolean
331
345
  }
332
346
 
333
347
  /** One due item that could NOT form a complete intent, with the exact reason. */
@@ -357,6 +371,13 @@ export interface HydrateFrontsPublishIntentsResult {
357
371
  incomplete: FrontsPublishIntentFailure[]
358
372
  }
359
373
 
374
+ export class FrontsScheduleOverflowError extends Error {
375
+ constructor(readonly limit: number) {
376
+ super(`Scheduled content exceeds the bounded ${limit}-item response.`)
377
+ this.name = 'FrontsScheduleOverflowError'
378
+ }
379
+ }
380
+
360
381
  /** `?, ?, ?` for an IN clause of `count` bound values. */
361
382
  function placeholders(count: number): string {
362
383
  return new Array(count).fill('?').join(', ')
@@ -379,10 +400,12 @@ interface VideoRefRow {
379
400
 
380
401
  interface PodcastRefRow {
381
402
  content_id: string
403
+ transcript: string | null
382
404
  audio_r2_key: string | null
383
405
  duration_seconds: number | null
384
406
  processing_source_url: string | null
385
407
  processing_source_kind: string | null
408
+ processing_trigger_token: string | null
386
409
  }
387
410
 
388
411
  interface MediaRow {
@@ -430,15 +453,17 @@ export async function hydrateFrontsPublishIntents(
430
453
  `SELECT ${CANDIDATE_COLUMNS}
431
454
  FROM content_items
432
455
  WHERE status = 'scheduled'
456
+ AND deleted_at IS NULL
433
457
  AND publish_at IS NOT NULL
434
458
  AND publish_at <= ?
435
459
  ORDER BY publish_at ASC
436
460
  LIMIT ?`,
437
461
  )
438
- .bind(options.toSeconds, limit)
462
+ .bind(options.toSeconds, limit + 1)
439
463
  .all<IntentCandidateRow>()
440
464
 
441
465
  const rows = candidates.results ?? []
466
+ if (rows.length > limit) throw new FrontsScheduleOverflowError(limit)
442
467
  if (rows.length === 0) return { intents: [], incomplete: [] }
443
468
 
444
469
  const refs = await loadContentRefSources(db, rows)
@@ -452,10 +477,11 @@ export async function hydrateFrontsPublishIntents(
452
477
  mediaPublicDomain,
453
478
  resolve: options.resolveContentUrl,
454
479
  })
455
- intents.push(intent)
456
- // The one dual-signal case (M2-B2): a podcast with a source but no
457
- // audio yet is a real intents[] entry AND an incomplete[] entry at
458
- // once, not either/or -- see buildContentRefs's module comment.
480
+ if (options.podcastSourceProductionEnabled !== false || !intent.content_refs.podcast_source) {
481
+ intents.push(intent)
482
+ }
483
+ // A podcast with a source but incomplete media is visible to the
484
+ // production consumer and remains explicitly unpublishable.
459
485
  if (dualSignal) incomplete.push(dualSignal)
460
486
  } catch (error) {
461
487
  if (error instanceof FrontsPublishIntentError) {
@@ -521,7 +547,7 @@ async function loadContentRefSources(
521
547
  if (podcastIds.length > 0) {
522
548
  const res = await db
523
549
  .prepare(
524
- `SELECT content_id, audio_r2_key, duration_seconds, processing_source_url, processing_source_kind FROM podcast_content
550
+ `SELECT content_id, transcript, audio_r2_key, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token FROM podcast_content
525
551
  WHERE content_id IN (${placeholders(podcastIds.length)})`,
526
552
  )
527
553
  .bind(...podcastIds)
@@ -596,7 +622,7 @@ async function buildIntent(
596
622
  }
597
623
 
598
624
  const deadline = Math.floor(row.publish_at)
599
- const { refs: contentRefs, incomplete } = buildContentRefs(row, refs, ctx)
625
+ const { refs: contentRefs, incomplete } = await buildContentRefs(row, refs, ctx)
600
626
  return {
601
627
  intent: {
602
628
  intent_id: frontsPublishIntentId(row.id, version),
@@ -621,21 +647,21 @@ async function buildIntent(
621
647
  /**
622
648
  * Per-type content refs, applying the absence rules in the module header.
623
649
  * Returns an `incomplete` note alongside a normally-complete refs object for
624
- * exactly one dual-signal case (M2-B2): a podcast with a processing source
625
- * captured but no audio yet. That item is genuinely not publishable (still
650
+ * a podcast with a processing source and incomplete audio/transcript evidence.
651
+ * That item is not publishable (still
626
652
  * worth an incomplete[] entry, same code as before) but is also not nothing
627
653
  * -- a pull consumer needs `podcast_source` to start production, so this
628
654
  * function returns it rather than throwing it away with the rest of the
629
655
  * intent the way every OTHER missing-required-value case still does.
630
656
  */
631
- function buildContentRefs(
657
+ async function buildContentRefs(
632
658
  row: IntentCandidateRow,
633
659
  refs: Awaited<ReturnType<typeof loadContentRefSources>>,
634
660
  ctx: {
635
661
  mediaPublicDomain: string
636
662
  resolve: (r: { type: ContentType; slug: string }) => string
637
663
  },
638
- ): { refs: FrontsPublishContentRefs; incomplete: FrontsPublishIntentFailure | null } {
664
+ ): Promise<{ refs: FrontsPublishContentRefs; incomplete: FrontsPublishIntentFailure | null }> {
639
665
  const publicUrl = ctx.resolve({ type: row.type, slug: row.slug })
640
666
  const canonicalUrl = row.canonical_url?.trim() || null
641
667
 
@@ -698,26 +724,52 @@ function buildContentRefs(
698
724
  if (row.type === 'podcast') {
699
725
  const found = refs.podcasts.get(row.id)
700
726
  const key = found?.audio_r2_key?.trim()
701
- if (found && key) {
727
+ const blocker = podcastPublishBlocker(found)
728
+ if (found && key && !blocker) {
702
729
  podcastAudio = {
703
730
  kind: 'r2-object',
704
731
  url: objectKeyUrl(key, ctx.mediaPublicDomain),
705
732
  duration_seconds: found.duration_seconds ?? null,
706
733
  }
707
734
  } else {
708
- const message = 'a podcast intent requires a non-empty podcast_content.audio_r2_key'
709
- const sourceUrl = found?.processing_source_url?.trim()
710
- const sourceKind = found?.processing_source_kind?.trim()
711
- if (sourceUrl && sourceKind === 'https') {
712
- podcastSource = { kind: 'https', url: sourceUrl }
735
+ const failure = blocker ?? {
736
+ code: 'missing_podcast_audio' as const,
737
+ message: 'Podcast audio is not ready.',
738
+ }
739
+ const sourceUrl = found?.processing_source_url
740
+ if (found && validPodcastSourceUrl(sourceUrl) && found.processing_source_kind === 'https') {
741
+ if (
742
+ typeof found.duration_seconds !== 'number' ||
743
+ !Number.isFinite(found.duration_seconds) ||
744
+ found.duration_seconds <= 0
745
+ ) {
746
+ throw new FrontsPublishIntentError(
747
+ 'podcast_source_duration_unavailable',
748
+ row.id,
749
+ 'Podcast source duration is not verified.',
750
+ )
751
+ }
752
+ podcastSource = {
753
+ kind: 'https',
754
+ url: sourceUrl,
755
+ duration_seconds: found.duration_seconds,
756
+ generation: await frontsPodcastSourceGeneration({
757
+ content_id: row.id,
758
+ slug: row.slug,
759
+ canonical_version: row.canonical_version as number,
760
+ processing_source_url: found.processing_source_url,
761
+ processing_source_kind: found.processing_source_kind,
762
+ processing_trigger_token: found.processing_trigger_token,
763
+ }),
764
+ }
713
765
  incomplete = {
714
766
  content_id: row.id,
715
- code: 'missing_podcast_audio',
716
- message,
767
+ code: failure.code,
768
+ message: failure.message,
717
769
  publish_at: Math.floor(row.publish_at ?? 0),
718
770
  }
719
771
  } else {
720
- throw new FrontsPublishIntentError('missing_podcast_audio', row.id, message)
772
+ throw new FrontsPublishIntentError(failure.code, row.id, failure.message)
721
773
  }
722
774
  }
723
775
  }
@@ -26,7 +26,15 @@
26
26
 
27
27
  import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
28
28
  import type { D1Database } from './d1.js'
29
+ import { FrontsScheduleOverflowError } from './fronts-publish-intent.js'
30
+ import { podcastPublishBlocker } from './podcast-source.js'
29
31
  import { type PublicationReconciler, publishOne } from './publication.js'
32
+ import {
33
+ assertCurrentPublicationSnapshot,
34
+ PublicationCurrentSnapshotError,
35
+ snapshotRecord,
36
+ } from './publication-current-guard.js'
37
+ import { getContentSnapshot } from './publisher.js'
30
38
 
31
39
  /** The five lifecycle states foundryd reports for a Fronts publish intent. */
32
40
  export const FRONTS_PUBLISH_STATUSES: readonly FrontsPublishStatus[] = [
@@ -101,12 +109,13 @@ export async function listFrontsPublishSchedule(
101
109
  `SELECT id, type, slug, title, channel, publish_at, fronts_publish_status
102
110
  FROM content_items
103
111
  WHERE status = 'scheduled'
112
+ AND deleted_at IS NULL
104
113
  AND publish_at IS NOT NULL
105
114
  AND publish_at <= ?
106
115
  ORDER BY publish_at ASC
107
116
  LIMIT ?`,
108
117
  )
109
- .bind(options.toSeconds, limit)
118
+ .bind(options.toSeconds, limit + 1)
110
119
  .all<{
111
120
  id: string
112
121
  type: ContentType
@@ -116,6 +125,7 @@ export async function listFrontsPublishSchedule(
116
125
  publish_at: number
117
126
  fronts_publish_status: FrontsPublishStatus | null
118
127
  }>()
128
+ if ((result.results?.length ?? 0) > limit) throw new FrontsScheduleOverflowError(limit)
119
129
  return (result.results ?? []).map((row) => ({
120
130
  id: row.id,
121
131
  type: row.type,
@@ -149,6 +159,14 @@ export interface FrontsPublishCallbackInput {
149
159
  error?: string | null
150
160
  /** Reference time (unix seconds), for tests. Defaults to Date.now(). */
151
161
  now?: number
162
+ /** Optional occurrence evidence carried by current publish workers. */
163
+ publishAt?: number | null
164
+ /** The route supplies the same host hook used by the editorial publish action. */
165
+ checkReadiness?: (item: {
166
+ id: string
167
+ type: ContentType
168
+ status: string
169
+ }) => Promise<{ ready: boolean; blockers?: string[]; message?: string }>
152
170
  }
153
171
 
154
172
  export interface FrontsPublishCallbackResult {
@@ -163,6 +181,8 @@ export interface FrontsPublishCallbackResult {
163
181
  contentFound: boolean
164
182
  /** Set when a `live` callback could not be committed through the ledger. */
165
183
  ledgerError: string | null
184
+ /** A retryable hold or stale/cancelled snapshot; never consumes event_id. */
185
+ rejection?: { code: string; status: 404 | 409 | 503; blockers?: string[]; message: string }
166
186
  }
167
187
 
168
188
  function hasReceipt(receipt: FrontsPublishReceipt | null | undefined): boolean {
@@ -231,9 +251,117 @@ export async function applyFrontsPublishCallback(
231
251
  let attemptId: string | null = null
232
252
  let ledgerError: string | null = null
233
253
  const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
254
+ if (shouldPublish && item.status === 'published') {
255
+ const current = await getContentSnapshot(db, input.contentId)
256
+ if (
257
+ !current ||
258
+ !(await matchesCommittedCallback(
259
+ db,
260
+ input.contentId,
261
+ current,
262
+ await hashReceiptIdentity(input.receipt),
263
+ input,
264
+ ))
265
+ ) {
266
+ return {
267
+ ok: false,
268
+ deduped: false,
269
+ status: input.status,
270
+ published: false,
271
+ attemptId: null,
272
+ contentFound: true,
273
+ ledgerError: null,
274
+ rejection: {
275
+ code: 'publish_snapshot_changed',
276
+ status: 409,
277
+ message: 'The committed publication does not match this callback.',
278
+ },
279
+ }
280
+ }
281
+ }
234
282
  if (shouldPublish && item.status !== 'published') {
283
+ const reject = (
284
+ code: string,
285
+ status: 404 | 409 | 503,
286
+ message: string,
287
+ blockers?: string[],
288
+ ): FrontsPublishCallbackResult => ({
289
+ ok: false,
290
+ deduped: false,
291
+ status: input.status,
292
+ published: false,
293
+ attemptId: null,
294
+ contentFound: true,
295
+ ledgerError: null,
296
+ rejection: { code, status, message, ...(blockers ? { blockers } : {}) },
297
+ })
298
+ const expectedSnapshot = await getContentSnapshot(db, input.contentId)
299
+ const expectedItem = snapshotRecord(expectedSnapshot?.item)
300
+ if (
301
+ !expectedSnapshot ||
302
+ expectedItem.deleted_at != null ||
303
+ expectedItem.status !== 'scheduled'
304
+ ) {
305
+ return reject('content_not_scheduled', 404, 'Content is no longer scheduled for publication.')
306
+ }
307
+ if (
308
+ (input.version != null && input.version !== expectedItem.canonical_version) ||
309
+ (input.publishAt != null && input.publishAt !== expectedItem.publish_at)
310
+ ) {
311
+ return reject(
312
+ 'publish_snapshot_changed',
313
+ 409,
314
+ 'The scheduled publication has been superseded.',
315
+ )
316
+ }
317
+ if (
318
+ typeof expectedItem.publish_at !== 'number' ||
319
+ !Number.isFinite(expectedItem.publish_at) ||
320
+ expectedItem.publish_at > now
321
+ ) {
322
+ return reject(
323
+ 'publish_not_ready',
324
+ 503,
325
+ 'The current scheduled publication time has not arrived.',
326
+ ['publish_not_due'],
327
+ )
328
+ }
329
+ const blocker =
330
+ expectedItem.type === 'podcast'
331
+ ? podcastPublishBlocker(snapshotRecord(expectedSnapshot.content))
332
+ : null
333
+ if (blocker) return reject('publish_not_ready', 503, blocker.message, [blocker.code])
334
+ if (input.checkReadiness) {
335
+ let timeout: ReturnType<typeof setTimeout> | undefined
336
+ try {
337
+ const readiness = await Promise.race([
338
+ input.checkReadiness({
339
+ id: input.contentId,
340
+ type: expectedItem.type as ContentType,
341
+ status: 'scheduled',
342
+ }),
343
+ new Promise<never>((_resolve, rejectTimeout) => {
344
+ timeout = setTimeout(() => rejectTimeout(new Error('readiness timed out')), 10_000)
345
+ }),
346
+ ])
347
+ if (readiness.ready !== true)
348
+ return reject(
349
+ 'publish_not_ready',
350
+ 503,
351
+ readiness.message || 'Content is not ready for publication.',
352
+ readiness.blockers,
353
+ )
354
+ } catch {
355
+ return reject('publish_not_ready', 503, 'Publish readiness could not be verified.', [
356
+ 'readiness_unavailable',
357
+ ])
358
+ } finally {
359
+ if (timeout !== undefined) clearTimeout(timeout)
360
+ }
361
+ }
362
+ const receiptIdentity = await hashReceiptIdentity(input.receipt)
363
+ const receiptSummary = summarizeReceipt(input.receipt)
235
364
  try {
236
- const receiptSummary = summarizeReceipt(input.receipt)
237
365
  const surface: PublicationReconciler = {
238
366
  name: 'fronts',
239
367
  async prepare() {
@@ -250,20 +378,43 @@ export async function applyFrontsPublishCallback(
250
378
  ...(publishedAt !== null ? { publishedAt } : {}),
251
379
  createdBy: 'foundry.fronts-publish',
252
380
  surfaces: [surface],
381
+ expectedCurrentSnapshot: expectedSnapshot,
382
+ expectedReceiptIdentity: receiptIdentity,
253
383
  })
254
384
  attemptId = receipt.attemptId
255
385
  published = true
256
386
  } 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.
387
+ // A concurrently published row may belong to a newer editor/source
388
+ // revision. Only a matching committed snapshot and full receipt prove
389
+ // this callback's success; status='published' alone proves nothing.
260
390
  const reread = await db
261
391
  .prepare('SELECT status FROM content_items WHERE id = ? LIMIT 1')
262
392
  .bind(input.contentId)
263
393
  .first<{ status: string }>()
264
394
  if (reread?.status === 'published') {
395
+ if (
396
+ !(await matchesCommittedCallback(db, input.contentId, expectedSnapshot, receiptIdentity))
397
+ ) {
398
+ return reject(
399
+ 'publish_snapshot_changed',
400
+ 409,
401
+ 'A different publication superseded this callback.',
402
+ )
403
+ }
265
404
  published = true
266
405
  } else {
406
+ if (error instanceof PublicationCurrentSnapshotError)
407
+ return reject('publish_snapshot_changed', 409, error.message)
408
+ const current = await getContentSnapshot(db, input.contentId)
409
+ try {
410
+ assertCurrentPublicationSnapshot(current ?? {}, expectedSnapshot)
411
+ } catch {
412
+ return reject(
413
+ 'publish_snapshot_changed',
414
+ 409,
415
+ 'Scheduled content or media changed before publication.',
416
+ )
417
+ }
267
418
  ledgerError = error instanceof Error ? error.message : String(error)
268
419
  }
269
420
  }
@@ -348,6 +499,73 @@ export async function applyFrontsPublishCallback(
348
499
  }
349
500
  }
350
501
 
502
+ async function hashReceiptIdentity(
503
+ receipt: FrontsPublishReceipt | null | undefined,
504
+ ): Promise<string> {
505
+ const bytes = new TextEncoder().encode(
506
+ JSON.stringify([
507
+ 'fronts-publish-receipt.v1',
508
+ receipt?.published_urls ?? null,
509
+ receipt?.platform_post_ids ?? null,
510
+ receipt?.sha256 ?? null,
511
+ ]),
512
+ )
513
+ const digest = await crypto.subtle.digest('SHA-256', bytes)
514
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
515
+ }
516
+
517
+ async function matchesCommittedCallback(
518
+ db: D1Database,
519
+ contentId: string,
520
+ expectedSnapshot: Record<string, unknown>,
521
+ receiptIdentity: string,
522
+ input?: Pick<FrontsPublishCallbackInput, 'version' | 'publishAt'>,
523
+ ): Promise<boolean> {
524
+ const current = await db
525
+ .prepare(`SELECT r.id AS revision_id, r.payload_json, a.receipts_json
526
+ FROM content_items c
527
+ JOIN content_revisions r ON r.id = c.published_revision_id AND r.content_id = c.id
528
+ JOIN content_publication_attempts a ON a.revision_id = r.id AND a.content_id = c.id
529
+ WHERE c.id = ? AND c.status = 'published' AND c.deleted_at IS NULL
530
+ AND r.created_by = 'foundry.fronts-publish' AND a.operation = 'publish' AND a.state = 'committed'
531
+ LIMIT 1`)
532
+ .bind(contentId)
533
+ .first<{ revision_id: string; payload_json: string; receipts_json: string }>()
534
+ if (!current) return false
535
+ try {
536
+ const snapshot = snapshotRecord(JSON.parse(current.payload_json))
537
+ if (snapshot.publication_receipt_identity !== receiptIdentity) return false
538
+ const committedItem = snapshotRecord(snapshot.item)
539
+ const expectedItem = snapshotRecord(expectedSnapshot.item)
540
+ if (expectedItem.status === 'published') {
541
+ if (
542
+ expectedItem.published_revision_id !== current.revision_id ||
543
+ expectedItem.publish_at != null ||
544
+ (input?.version != null && input.version !== committedItem.canonical_version) ||
545
+ (input?.publishAt != null && input.publishAt !== committedItem.publish_at)
546
+ )
547
+ return false
548
+ // Commit deliberately changes only these scheduled guard fields.
549
+ // Every source/editor field must still match the immutable revision.
550
+ expectedSnapshot = {
551
+ ...expectedSnapshot,
552
+ item: { ...expectedItem, status: 'scheduled', publish_at: committedItem.publish_at },
553
+ }
554
+ }
555
+ assertCurrentPublicationSnapshot(snapshot, expectedSnapshot)
556
+ const receipts: unknown = JSON.parse(current.receipts_json)
557
+ return (
558
+ Array.isArray(receipts) &&
559
+ receipts.some((value) => {
560
+ const receipt = snapshotRecord(value)
561
+ return receipt.name === 'fronts' && receipt.phase === 'commit' && receipt.status === 'ok'
562
+ })
563
+ )
564
+ } catch {
565
+ return false
566
+ }
567
+ }
568
+
351
569
  /** Replay a stored callback outcome by event_id, or null when not yet recorded. */
352
570
  async function readStoredOutcome(
353
571
  db: D1Database,
@@ -155,6 +155,10 @@ export {
155
155
  type OgCardSpecOpts,
156
156
  type OgImageRenderer,
157
157
  } from './og-render.js'
158
+ export {
159
+ type FrontsPodcastSourceSnapshot,
160
+ frontsPodcastSourceGeneration,
161
+ } from './podcast-source.js'
158
162
  // Bounded one-item publication kernel.
159
163
  export {
160
164
  ContentArchiveImportLimitError,
@@ -0,0 +1,82 @@
1
+ /** Current CMS evidence used by the signed schedule and media callback guards. */
2
+ export interface FrontsPodcastSourceSnapshot {
3
+ content_id: string
4
+ slug: string
5
+ canonical_version: number
6
+ processing_source_url: string | null
7
+ processing_source_kind: string | null
8
+ processing_trigger_token: string | null
9
+ }
10
+
11
+ /**
12
+ * Stable over polling, machine media callbacks and rescheduling. Canonical
13
+ * editor/source changes invalidate the old production. Neither the raw source
14
+ * token nor URL needs to appear in callback/notification identities.
15
+ */
16
+ export async function frontsPodcastSourceGeneration(
17
+ input: FrontsPodcastSourceSnapshot,
18
+ ): Promise<string> {
19
+ const bytes = new TextEncoder().encode(
20
+ JSON.stringify([
21
+ 'fronts-podcast-source.v1',
22
+ input.content_id,
23
+ input.slug,
24
+ input.canonical_version,
25
+ input.processing_source_url,
26
+ input.processing_source_kind,
27
+ input.processing_trigger_token,
28
+ ]),
29
+ )
30
+ const digest = await crypto.subtle.digest('SHA-256', bytes)
31
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
32
+ }
33
+
34
+ export type PodcastPublishBlockerCode =
35
+ | 'missing_podcast_audio'
36
+ | 'podcast_transcript_pending'
37
+ | 'podcast_source_duration_unavailable'
38
+
39
+ /** Portable minimum; hosts may enforce additional object/access checks. */
40
+ export function podcastPublishBlocker(
41
+ content:
42
+ | {
43
+ audio_r2_key?: unknown
44
+ transcript?: unknown
45
+ processing_source_url?: unknown
46
+ duration_seconds?: unknown
47
+ }
48
+ | null
49
+ | undefined,
50
+ ): { code: PodcastPublishBlockerCode; message: string } | null {
51
+ if (typeof content?.audio_r2_key !== 'string' || !content.audio_r2_key.trim()) {
52
+ return { code: 'missing_podcast_audio', message: 'Podcast audio is not ready.' }
53
+ }
54
+ if (typeof content.transcript !== 'string' || !content.transcript.trim()) {
55
+ return { code: 'podcast_transcript_pending', message: 'Podcast transcript is not ready.' }
56
+ }
57
+ if (
58
+ typeof content.processing_source_url === 'string' &&
59
+ content.processing_source_url.trim() &&
60
+ (typeof content.duration_seconds !== 'number' ||
61
+ !Number.isFinite(content.duration_seconds) ||
62
+ content.duration_seconds <= 0)
63
+ ) {
64
+ return {
65
+ code: 'podcast_source_duration_unavailable',
66
+ message: 'Podcast source duration is not verified.',
67
+ }
68
+ }
69
+ return null
70
+ }
71
+
72
+ export function validPodcastSourceUrl(value: string | null | undefined): value is string {
73
+ if (!value || !/^https:\/\/\S+$/.test(value) || Array.from(value).length > 2048) return false
74
+ try {
75
+ const url = new URL(value)
76
+ return (
77
+ url.protocol === 'https:' && !!url.hostname && !url.username && !url.password && !url.hash
78
+ )
79
+ } catch {
80
+ return false
81
+ }
82
+ }