@growth-labs/cms 0.6.6 → 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 (72) 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 +1 -1
  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/package.json +1 -1
  57. package/src/engine/foundry-dispatch.ts +67 -12
  58. package/src/engine/fronts-publish-intent.ts +78 -26
  59. package/src/engine/fronts-publish.ts +223 -5
  60. package/src/engine/index.ts +4 -0
  61. package/src/engine/podcast-source.ts +82 -0
  62. package/src/engine/publication-current-guard.ts +87 -0
  63. package/src/engine/publication.ts +23 -0
  64. package/src/engine/publisher.ts +21 -13
  65. package/src/engine/soft-delete.ts +2 -2
  66. package/src/providers/types.ts +6 -1
  67. package/src/routes/content.ts +3 -1
  68. package/src/routes/fronts-publish.ts +63 -25
  69. package/src/routes/index.ts +1 -1
  70. package/src/ui/api/_content-config.ts +2 -0
  71. package/src/ui/api/fronts/publish-callback.ts +2 -8
  72. package/src/ui/api/fronts/schedule.ts +2 -8
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ /** A guard lives with its revision, so retries retain the same editorial proof. */
2
+ export const CURRENT_PUBLICATION_GUARD = 'cms.scheduled-publication-current.v1'
3
+
4
+ export class PublicationCurrentSnapshotError extends Error {
5
+ constructor() {
6
+ super('Scheduled content or media changed before publication.')
7
+ this.name = 'PublicationCurrentSnapshotError'
8
+ }
9
+ }
10
+
11
+ export function snapshotRecord(value: unknown): Record<string, unknown> {
12
+ return value && typeof value === 'object' && !Array.isArray(value)
13
+ ? (value as Record<string, unknown>)
14
+ : {}
15
+ }
16
+
17
+ const ITEM_FIELDS = [
18
+ 'id',
19
+ 'type',
20
+ 'slug',
21
+ 'status',
22
+ 'deleted_at',
23
+ 'canonical_version',
24
+ 'publish_at',
25
+ ] as const
26
+ const PODCAST_FIELDS = [
27
+ 'audio_r2_key',
28
+ 'transcript',
29
+ 'duration_seconds',
30
+ 'processing_source_url',
31
+ 'processing_source_kind',
32
+ 'processing_trigger_token',
33
+ ] as const
34
+ const VIDEO_FIELDS = [
35
+ 'video_id',
36
+ 'duration_seconds',
37
+ 'processing_source_url',
38
+ 'processing_source_kind',
39
+ 'processing_trigger_token',
40
+ 'processing_correlation_id',
41
+ 'processing_state',
42
+ 'hls_ready',
43
+ 'hls_manifest_url',
44
+ 'transcript_ready',
45
+ 'transcript_url',
46
+ ] as const
47
+
48
+ export function assertCurrentPublicationSnapshot(
49
+ current: Record<string, unknown>,
50
+ expected: Record<string, unknown>,
51
+ ): void {
52
+ const item = snapshotRecord(current.item)
53
+ const expectedItem = snapshotRecord(expected.item)
54
+ if (
55
+ item.status !== 'scheduled' ||
56
+ item.deleted_at != null ||
57
+ ITEM_FIELDS.some((field) => item[field] !== expectedItem[field])
58
+ ) {
59
+ throw new PublicationCurrentSnapshotError()
60
+ }
61
+ const fields =
62
+ item.type === 'podcast' ? PODCAST_FIELDS : item.type === 'video' ? VIDEO_FIELDS : []
63
+ const content = snapshotRecord(current.content)
64
+ const expectedContent = snapshotRecord(expected.content)
65
+ if (fields.some((field) => content[field] !== expectedContent[field]))
66
+ throw new PublicationCurrentSnapshotError()
67
+ }
68
+
69
+ /** Uses the immutable revision in the existing pointer CAS; no second write. */
70
+ export function currentPublicationSqlGuard(revisionAlias: string): string {
71
+ const item = (field: string) => `json_extract(${revisionAlias}.payload_json, '$.item.${field}')`
72
+ const content = (field: string) =>
73
+ `json_extract(${revisionAlias}.payload_json, '$.content.${field}')`
74
+ const matches = (table: string, fields: readonly string[]) => `EXISTS (
75
+ SELECT 1 FROM ${table} current_media WHERE current_media.content_id = content_items.id
76
+ AND ${fields.map((field) => `current_media.${field} IS ${content(field)}`).join(' AND ')}
77
+ )`
78
+ return `(json_extract(${revisionAlias}.payload_json, '$.publication_guard') IS NULL OR (
79
+ json_extract(${revisionAlias}.payload_json, '$.publication_guard') = '${CURRENT_PUBLICATION_GUARD}'
80
+ AND content_items.status = 'scheduled' AND content_items.deleted_at IS NULL
81
+ AND ${ITEM_FIELDS.filter((field) => field !== 'deleted_at')
82
+ .map((field) => `content_items.${field} IS ${item(field)}`)
83
+ .join(' AND ')}
84
+ AND (${item('type')} <> 'podcast' OR ${matches('podcast_content', PODCAST_FIELDS)})
85
+ AND (${item('type')} <> 'video' OR ${matches('video_content', VIDEO_FIELDS)})
86
+ ))`
87
+ }
@@ -11,6 +11,11 @@ import {
11
11
  serializeContentMetadata,
12
12
  } from './content-metadata.js'
13
13
  import type { D1Database, D1Result } from './d1.js'
14
+ import {
15
+ assertCurrentPublicationSnapshot,
16
+ CURRENT_PUBLICATION_GUARD,
17
+ currentPublicationSqlGuard,
18
+ } from './publication-current-guard.js'
14
19
  import { evaluateArticleBody } from './publish-guard.js'
15
20
  import {
16
21
  type CreateContentInput,
@@ -81,6 +86,10 @@ export interface PublishOneInput {
81
86
  requiredSurfaceNames?: string[]
82
87
  packageVersion?: string | null
83
88
  surfaceHookTimeoutMs?: number
89
+ /** Machine publication must still describe this scheduled editorial/media snapshot. */
90
+ expectedCurrentSnapshot?: Record<string, unknown>
91
+ /** Digest of the external receipt bound to a guarded machine publication. */
92
+ expectedReceiptIdentity?: string
84
93
  }
85
94
 
86
95
  export interface PublishOneReceipt {
@@ -763,6 +772,8 @@ async function createRevisionAndPublicationAttempt(
763
772
  packageVersion: string | null
764
773
  now: number
765
774
  createdBy?: string | null
775
+ expectedCurrentSnapshot?: Record<string, unknown>
776
+ expectedReceiptIdentity?: string
766
777
  },
767
778
  ): Promise<{
768
779
  revisionId: string
@@ -771,6 +782,15 @@ async function createRevisionAndPublicationAttempt(
771
782
  }> {
772
783
  const snapshot = await getContentSnapshot(db, input.contentId)
773
784
  if (!snapshot) throw new PublicationNotFoundError(`content not found: ${input.contentId}`)
785
+ if (input.expectedCurrentSnapshot) {
786
+ assertCurrentPublicationSnapshot(snapshot, input.expectedCurrentSnapshot)
787
+ snapshot.publication_guard = CURRENT_PUBLICATION_GUARD
788
+ if (input.expectedReceiptIdentity !== undefined) {
789
+ if (!/^[a-f0-9]{64}$/.test(input.expectedReceiptIdentity))
790
+ throw new Error('Invalid guarded publication receipt identity')
791
+ snapshot.publication_receipt_identity = input.expectedReceiptIdentity
792
+ }
793
+ }
774
794
  assertPublicationSnapshotBodyValid(snapshot, input.contentId)
775
795
  const revisionId = crypto.randomUUID()
776
796
  const attemptId = crypto.randomUUID()
@@ -1597,6 +1617,7 @@ async function movePointerAndBeginCommit(
1597
1617
  AND target_revision.content_id = ?
1598
1618
  AND json_type(target_revision.payload_json, '$.item.slug') = 'text'
1599
1619
  AND length(json_extract(target_revision.payload_json, '$.item.slug')) BETWEEN 1 AND 512
1620
+ ${attempt.operation === 'publish' ? `AND ${currentPublicationSqlGuard('target_revision')}` : ''}
1600
1621
  AND NOT EXISTS (
1601
1622
  SELECT 1
1602
1623
  FROM content_items other_item
@@ -1816,6 +1837,8 @@ export async function publishOne(
1816
1837
  packageVersion: input.packageVersion ?? null,
1817
1838
  now,
1818
1839
  createdBy: input.createdBy,
1840
+ expectedCurrentSnapshot: input.expectedCurrentSnapshot,
1841
+ expectedReceiptIdentity: input.expectedReceiptIdentity,
1819
1842
  })
1820
1843
  const reconciliationInput: PublicationReconciliationInput = {
1821
1844
  operation: 'publish',
@@ -882,7 +882,20 @@ export async function updateContentItem(
882
882
  const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
883
883
  ? (podcastContent.audioR2Key ?? current.audio_r2_key)
884
884
  : current.audio_r2_key
885
- const sourceChanged = audioR2Key !== current.audio_r2_key
885
+ const audioChanged = audioR2Key !== current.audio_r2_key
886
+ const sourceUrl = hasOwn(podcastContent, 'processingSourceUrl')
887
+ ? (podcastContent.processingSourceUrl ?? null)
888
+ : audioChanged
889
+ ? null
890
+ : current.processing_source_url
891
+ const sourceKind = hasOwn(podcastContent, 'processingSourceKind')
892
+ ? (podcastContent.processingSourceKind ?? null)
893
+ : audioChanged
894
+ ? null
895
+ : current.processing_source_kind
896
+ const capturedSourceChanged =
897
+ sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
898
+ const sourceChanged = audioChanged || capturedSourceChanged
886
899
  const durationProvided = hasOwn(podcastContent, 'durationSeconds')
887
900
  const reusesSavedDuration =
888
901
  !sourceChanged &&
@@ -892,7 +905,7 @@ export async function updateContentItem(
892
905
  : await resolveMediaDuration(
893
906
  db,
894
907
  'podcast',
895
- audioR2Key,
908
+ capturedSourceChanged ? sourceUrl : audioR2Key,
896
909
  durationProvided ? podcastContent.durationSeconds : undefined,
897
910
  )
898
911
  const replacementTranscriptProvided =
@@ -906,18 +919,14 @@ export async function updateContentItem(
906
919
  : replacementTranscriptProvided
907
920
  ? (podcastContent.transcript as string)
908
921
  : current.transcript,
909
- audioR2Key,
922
+ audioR2Key: capturedSourceChanged && !audioChanged ? '' : audioR2Key,
910
923
  durationSeconds:
911
924
  durationProvided || sourceChanged
912
925
  ? resolvedDuration
913
926
  : (resolvedDuration ?? current.duration_seconds),
914
927
  processingTriggerToken: sourceChanged ? null : current.processing_trigger_token,
915
- sourceUrl: hasOwn(podcastContent, 'processingSourceUrl')
916
- ? (podcastContent.processingSourceUrl ?? null)
917
- : current.processing_source_url,
918
- sourceKind: hasOwn(podcastContent, 'processingSourceKind')
919
- ? (podcastContent.processingSourceKind ?? null)
920
- : current.processing_source_kind,
928
+ sourceUrl,
929
+ sourceKind,
921
930
  }
922
931
  } else {
923
932
  podcastUpdatePlan = null
@@ -1146,10 +1155,9 @@ export async function updateContentItem(
1146
1155
  metadata_json = ?,
1147
1156
  updated_at = unixepoch(),
1148
1157
  -- The canonical revision counter behind the Fronts publish intent id
1149
- -- masthead:<content_id>:<version> (migration 0027). This is the ONE seam
1150
- -- that advances it, so an intent identity changes exactly when the item's
1151
- -- canonical content does -- and never on a fronts_publish_status marker
1152
- -- write or a schedule poll.
1158
+ -- masthead:<content_id>:<version> (migration 0027). Canonical edits and
1159
+ -- actual delete/restore/archive transitions advance it; scheduling,
1160
+ -- fronts_publish_status marker writes and polls keep it stable.
1153
1161
  canonical_version = canonical_version + 1
1154
1162
  WHERE id = ?
1155
1163
  `,
@@ -8,7 +8,7 @@ export async function softDeleteContent(
8
8
  const ts = now ?? Math.floor(Date.now() / 1000)
9
9
  const res = await db
10
10
  .prepare(
11
- 'UPDATE content_items SET deleted_at = ?, updated_at = unixepoch() WHERE id = ? AND deleted_at IS NULL',
11
+ 'UPDATE content_items SET deleted_at = ?, updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND deleted_at IS NULL',
12
12
  )
13
13
  .bind(ts, id)
14
14
  .run()
@@ -18,7 +18,7 @@ export async function softDeleteContent(
18
18
  export async function restoreContent(db: D1Database, id: string): Promise<boolean> {
19
19
  const res = await db
20
20
  .prepare(
21
- 'UPDATE content_items SET deleted_at = NULL, updated_at = unixepoch() WHERE id = ? AND deleted_at IS NOT NULL',
21
+ 'UPDATE content_items SET deleted_at = NULL, updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND deleted_at IS NOT NULL',
22
22
  )
23
23
  .bind(id)
24
24
  .run()
@@ -397,8 +397,13 @@ export interface FoundryJobSpec {
397
397
  description?: string | null
398
398
  processingTriggerTokenHash?: string
399
399
  }
400
+ export interface FoundryDispatchResult {
401
+ correlationId: string
402
+ /** Return source evidence; the CMS owns its atomic capture with the token. */
403
+ podcastSource?: { url: string; kind: 'https' }
404
+ }
400
405
  export interface FoundryDispatch {
401
- dispatchVideo(jobSpec: FoundryJobSpec): Promise<{ correlationId: string }>
406
+ dispatchVideo(jobSpec: FoundryJobSpec): Promise<FoundryDispatchResult>
402
407
  getJob(correlationId: string): Promise<{ state: string } | null>
403
408
  }
404
409
 
@@ -197,6 +197,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
197
197
  dispatchFaq?: DispatchFaqHook
198
198
  /** Optional host-specific readiness guard. A not-ready result blocks publish. */
199
199
  publishReadiness?: PublishReadinessHook
200
+ /** Host's existing production rollback control; omitted means enabled. */
201
+ podcastSourceProductionEnabled?: boolean
200
202
  /** Internal machine-route constraint: atomically update only in this status. */
201
203
  updateStatusConstraint?: ContentStatus
202
204
  /**
@@ -1890,7 +1892,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1890
1892
  if (action === 'archive') {
1891
1893
  await ctx.db
1892
1894
  .prepare(
1893
- "UPDATE content_items SET status = 'archived', updated_at = unixepoch() WHERE id = ?",
1895
+ "UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
1894
1896
  )
1895
1897
  .bind(id)
1896
1898
  .run()