@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
@@ -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
  /**
@@ -705,7 +707,9 @@ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
705
707
  if (type === 'podcast') {
706
708
  return ctx.db
707
709
  .prepare(
708
- 'SELECT transcript, audio_r2_key, duration_seconds FROM podcast_content WHERE content_id = ? LIMIT 1',
710
+ `SELECT transcript, audio_r2_key, duration_seconds,
711
+ processing_source_url, processing_source_kind
712
+ FROM podcast_content WHERE content_id = ? LIMIT 1`,
709
713
  )
710
714
  .bind(id)
711
715
  .first()
@@ -1888,7 +1892,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1888
1892
  if (action === 'archive') {
1889
1893
  await ctx.db
1890
1894
  .prepare(
1891
- "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'",
1892
1896
  )
1893
1897
  .bind(id)
1894
1898
  .run()
@@ -29,9 +29,13 @@ import {
29
29
  isFrontsPublishEnabled,
30
30
  listFrontsPublishSchedule,
31
31
  } from '../engine/fronts-publish.js'
32
- import { hydrateFrontsPublishIntents } from '../engine/fronts-publish-intent.js'
32
+ import {
33
+ FrontsScheduleOverflowError,
34
+ hydrateFrontsPublishIntents,
35
+ } from '../engine/fronts-publish-intent.js'
33
36
  import { publicUrlFor } from '../ui/screens/public-url.js'
34
- import { type CmsRouteConfig, resolveConfig } from './config.js'
37
+ import { resolveConfig } from './config.js'
38
+ import type { ContentRouteConfig } from './content.js'
35
39
  import { json, type RouteContext } from './context.js'
36
40
 
37
41
  /**
@@ -112,6 +116,7 @@ const CallbackSchema = z
112
116
  intent_id: z.string().min(1).nullish(),
113
117
  content_id: z.string().min(1),
114
118
  version: z.number().int().nullish(),
119
+ publish_at: z.number().int().positive().nullish(),
115
120
  correlation_id: z.string().min(1).nullish(),
116
121
  status: z.enum(['queued', 'publishing', 'live', 'late', 'failed']),
117
122
  occurred_at: z.union([z.string(), z.number()]).nullish(),
@@ -151,7 +156,7 @@ function parseWindow(
151
156
  return { fromSeconds, toSeconds }
152
157
  }
153
158
 
154
- export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublishRouteHandlers {
159
+ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
155
160
  const resolved = resolveConfig(config)
156
161
  return {
157
162
  async schedule(ctx) {
@@ -184,28 +189,37 @@ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublish
184
189
  // predicate, and `incomplete` names every due item that cannot form a
185
190
  // complete intent, so a missing canonical field is an explicit, visible
186
191
  // 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
- })
192
+ try {
193
+ const items = await listFrontsPublishSchedule(ctx.db, {
194
+ toSeconds: window.toSeconds,
195
+ nowSeconds: now,
196
+ })
197
+ const hydrated = await hydrateFrontsPublishIntents(ctx.db, {
198
+ toSeconds: window.toSeconds,
199
+ nowSeconds: now,
200
+ mediaPublicDomain: resolved.mediaPublicDomain,
201
+ podcastSourceProductionEnabled: config.podcastSourceProductionEnabled,
202
+ // Site-RELATIVE paths: the CMS does not know the site origin, and the
203
+ // consumer joins its own trusted configured origin. A host-supplied
204
+ // origin is deliberately deferred (packages-docs/cms.md).
205
+ resolveContentUrl: (row) => publicUrlFor(row),
206
+ })
207
+ return json({
208
+ ok: true,
209
+ now,
210
+ window: { from: window.fromSeconds, to: window.toSeconds },
211
+ count: items.length,
212
+ items,
213
+ intents: hydrated.intents,
214
+ incomplete: hydrated.incomplete,
215
+ })
216
+ } catch (error) {
217
+ if (error instanceof FrontsScheduleOverflowError) {
218
+ console.error(`[cms] fronts/schedule unavailable: ${error.message}`)
219
+ return json({ ok: false, error: 'schedule_overflow', message: error.message }, 503)
220
+ }
221
+ throw error
222
+ }
209
223
  },
210
224
 
211
225
  async publishCallback(ctx) {
@@ -253,16 +267,40 @@ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublish
253
267
  intentId: parsed.data.intent_id ?? null,
254
268
  contentId: parsed.data.content_id,
255
269
  version: parsed.data.version ?? null,
270
+ publishAt: parsed.data.publish_at ?? null,
256
271
  correlationId: parsed.data.correlation_id ?? null,
257
272
  status: parsed.data.status,
258
273
  occurredAt: parsed.data.occurred_at ?? null,
259
274
  receipt: parsed.data.receipt ?? null,
260
275
  error: parsed.data.error ?? null,
276
+ checkReadiness: config.publishReadiness
277
+ ? (item) =>
278
+ config.publishReadiness!({
279
+ ctx,
280
+ contentId: item.id,
281
+ type: item.type,
282
+ status: item.status,
283
+ })
284
+ : undefined,
261
285
  })
262
286
 
263
287
  if (!result.contentFound) {
264
288
  return json({ error: 'content_not_found', contentId: parsed.data.content_id }, 404)
265
289
  }
290
+ if (result.rejection) {
291
+ console.warn(
292
+ `[cms] fronts/publish-callback rejected for content ${parsed.data.content_id}: ${result.rejection.code}`,
293
+ )
294
+ return json(
295
+ {
296
+ ok: false,
297
+ error: result.rejection.code,
298
+ message: result.rejection.message,
299
+ ...(result.rejection.blockers ? { blockers: result.rejection.blockers } : {}),
300
+ },
301
+ result.rejection.status,
302
+ )
303
+ }
266
304
  if (result.ledgerError) {
267
305
  // A confirmed `live` that could not be committed through the ledger is a
268
306
  // hard, loud failure — never a silent zero.
@@ -312,7 +312,7 @@ export function createCmsRoutes(
312
312
  authz: config.authz,
313
313
  activeWorkspaceId: config.activeWorkspaceId,
314
314
  }),
315
- frontsPublish: createFrontsPublishRoutes(base),
315
+ frontsPublish: createFrontsPublishRoutes(config),
316
316
  foundryPublish: createFoundryPublishRoutes(config),
317
317
  }
318
318
  }
@@ -24,6 +24,7 @@ type CmsHooksLocals = ContentRouteConfig['hooks'] & {
24
24
  dispatchFaq?: DispatchFaqHook
25
25
  dispatchWebhook?: DispatchWebhookHook
26
26
  publishReadiness?: PublishReadinessHook
27
+ podcastSourceProductionEnabled?: boolean
27
28
  }
28
29
 
29
30
  /**
@@ -73,5 +74,6 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
73
74
  dispatchFaq: hooks.dispatchFaq,
74
75
  dispatchWebhook: hooks.dispatchWebhook,
75
76
  publishReadiness: hooks.publishReadiness,
77
+ podcastSourceProductionEnabled: hooks.podcastSourceProductionEnabled,
76
78
  }
77
79
  }
@@ -5,14 +5,8 @@
5
5
  // content_publication_attempts 2PC ledger. HMAC-SHA256 (X-Foundry-Signature vs
6
6
  // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher.
7
7
  import type { APIContext } from 'astro'
8
- import type { RouteContext } from '../../../routes/context.js'
9
8
  import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
10
- import { buildSiteAuthz } from '../_authz.js'
11
-
12
- function toCtx(c: APIContext): RouteContext {
13
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
- return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
- }
9
+ import { toContentConfig, toContentCtx } from '../_content-config.js'
16
10
 
17
11
  export const POST = (c: APIContext) =>
18
- createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).publishCallback(toCtx(c))
12
+ createFrontsPublishRoutes(toContentConfig(c)).publishCallback(toContentCtx(c, {}))
@@ -5,14 +5,8 @@
5
5
  // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher. Sibling of the
6
6
  // content foundry-callback / insights-ingest M2M routes.
7
7
  import type { APIContext } from 'astro'
8
- import type { RouteContext } from '../../../routes/context.js'
9
8
  import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
10
- import { buildSiteAuthz } from '../_authz.js'
11
-
12
- function toCtx(c: APIContext): RouteContext {
13
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
- return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
- }
9
+ import { toContentConfig, toContentCtx } from '../_content-config.js'
16
10
 
17
11
  export const GET = (c: APIContext) =>
18
- createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).schedule(toCtx(c))
12
+ createFrontsPublishRoutes(toContentConfig(c)).schedule(toContentCtx(c, {}))
@@ -316,7 +316,7 @@ export function PublishTab({
316
316
  {/* Scheduled date + time inputs */}
317
317
  {showScheduleControls && (
318
318
  <div style={schedGrid} className="fade-only">
319
- <Field label="Date">
319
+ <Field label="Date (UTC)">
320
320
  <input
321
321
  className="input"
322
322
  type="date"
@@ -324,7 +324,7 @@ export function PublishTab({
324
324
  onChange={(e) => setSchedDate(e.target.value)}
325
325
  />
326
326
  </Field>
327
- <Field label="Time">
327
+ <Field label="Time (UTC)">
328
328
  <input
329
329
  className="input"
330
330
  type="time"