@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.
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +36 -7
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +15 -4
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +52 -19
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +19 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +158 -5
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +28 -0
- package/dist/engine/podcast-source.d.ts.map +1 -0
- package/dist/engine/podcast-source.js +50 -0
- package/dist/engine/podcast-source.js.map +1 -0
- package/dist/engine/publication-current-guard.d.ts +10 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -0
- package/dist/engine/publication-current-guard.js +76 -0
- package/dist/engine/publication-current-guard.js.map +1 -0
- package/dist/engine/publication.d.ts +4 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +13 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +20 -13
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.js +2 -2
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/providers/types.d.ts +9 -3
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content.d.ts +2 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +4 -2
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -2
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +52 -23
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/routes/index.js +1 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/schema/layout.d.ts +2 -2
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +1 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
- package/dist/ui/api/fronts/publish-callback.js +2 -6
- package/dist/ui/api/fronts/publish-callback.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -6
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/dist/ui/inspector/PublishTab.js +1 -1
- package/dist/ui/inspector/PublishTab.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +67 -12
- package/src/engine/fronts-publish-intent.ts +78 -26
- package/src/engine/fronts-publish.ts +223 -5
- package/src/engine/index.ts +4 -0
- package/src/engine/podcast-source.ts +82 -0
- package/src/engine/publication-current-guard.ts +87 -0
- package/src/engine/publication.ts +23 -0
- package/src/engine/publisher.ts +21 -13
- package/src/engine/soft-delete.ts +2 -2
- package/src/providers/types.ts +6 -1
- package/src/routes/content.ts +6 -2
- package/src/routes/fronts-publish.ts +63 -25
- package/src/routes/index.ts +1 -1
- package/src/ui/api/_content-config.ts +2 -0
- package/src/ui/api/fronts/publish-callback.ts +2 -8
- package/src/ui/api/fronts/schedule.ts +2 -8
- 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
|
|
23
|
-
//
|
|
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
|
|
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
|
|
126
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
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
|
-
*
|
|
625
|
-
*
|
|
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
|
-
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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:
|
|
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(
|
|
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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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,
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
+
}
|