@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
|
@@ -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',
|
package/src/engine/publisher.ts
CHANGED
|
@@ -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
|
|
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
|
|
916
|
-
|
|
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).
|
|
1150
|
-
--
|
|
1151
|
-
--
|
|
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()
|
package/src/providers/types.ts
CHANGED
|
@@ -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<
|
|
406
|
+
dispatchVideo(jobSpec: FoundryJobSpec): Promise<FoundryDispatchResult>
|
|
402
407
|
getJob(correlationId: string): Promise<{ state: string } | null>
|
|
403
408
|
}
|
|
404
409
|
|
package/src/routes/content.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
package/src/routes/index.ts
CHANGED
|
@@ -312,7 +312,7 @@ export function createCmsRoutes(
|
|
|
312
312
|
authz: config.authz,
|
|
313
313
|
activeWorkspaceId: config.activeWorkspaceId,
|
|
314
314
|
}),
|
|
315
|
-
frontsPublish: createFrontsPublishRoutes(
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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"
|