@growth-labs/cms 0.4.0 → 0.5.0

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 (55) hide show
  1. package/README.md +24 -3
  2. package/dist/engine/foundry-dispatch.d.ts +5 -1
  3. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  4. package/dist/engine/foundry-dispatch.js +19 -3
  5. package/dist/engine/foundry-dispatch.js.map +1 -1
  6. package/dist/engine/index.d.ts +1 -1
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +1 -1
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/engine/publisher.d.ts +4 -0
  11. package/dist/engine/publisher.d.ts.map +1 -1
  12. package/dist/engine/publisher.js +190 -54
  13. package/dist/engine/publisher.js.map +1 -1
  14. package/dist/providers/types.d.ts +1 -0
  15. package/dist/providers/types.d.ts.map +1 -1
  16. package/dist/routes/content.d.ts.map +1 -1
  17. package/dist/routes/content.js +76 -16
  18. package/dist/routes/content.js.map +1 -1
  19. package/dist/routes/context.d.ts +1 -0
  20. package/dist/routes/context.d.ts.map +1 -1
  21. package/dist/routes/context.js.map +1 -1
  22. package/dist/routes/media.d.ts.map +1 -1
  23. package/dist/routes/media.js +53 -31
  24. package/dist/routes/media.js.map +1 -1
  25. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  26. package/dist/ui/editor/ContentForm.js +65 -10
  27. package/dist/ui/editor/ContentForm.js.map +1 -1
  28. package/dist/ui/editor/content-payload.d.ts +2 -0
  29. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  30. package/dist/ui/editor/content-payload.js +15 -4
  31. package/dist/ui/editor/content-payload.js.map +1 -1
  32. package/dist/ui/editor/editor-media-duration.d.ts +24 -0
  33. package/dist/ui/editor/editor-media-duration.d.ts.map +1 -0
  34. package/dist/ui/editor/editor-media-duration.js +51 -0
  35. package/dist/ui/editor/editor-media-duration.js.map +1 -0
  36. package/dist/ui/screens/MediaScreen.d.ts.map +1 -1
  37. package/dist/ui/screens/MediaScreen.js +8 -1
  38. package/dist/ui/screens/MediaScreen.js.map +1 -1
  39. package/dist/ui/screens/media-upload.d.ts +9 -2
  40. package/dist/ui/screens/media-upload.d.ts.map +1 -1
  41. package/dist/ui/screens/media-upload.js +24 -0
  42. package/dist/ui/screens/media-upload.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/engine/foundry-dispatch.ts +32 -4
  45. package/src/engine/index.ts +1 -0
  46. package/src/engine/publisher.ts +297 -97
  47. package/src/providers/types.ts +1 -0
  48. package/src/routes/content.ts +91 -16
  49. package/src/routes/context.ts +1 -0
  50. package/src/routes/media.ts +63 -41
  51. package/src/ui/editor/ContentForm.tsx +121 -4
  52. package/src/ui/editor/content-payload.ts +20 -2
  53. package/src/ui/editor/editor-media-duration.ts +75 -0
  54. package/src/ui/screens/MediaScreen.tsx +13 -2
  55. package/src/ui/screens/media-upload.ts +41 -2
@@ -22,6 +22,7 @@ import {
22
22
  applyFoundryCallback,
23
23
  type DispatchResult,
24
24
  dispatchToFoundry,
25
+ FoundryDispatchPrerequisiteError,
25
26
  type ProcessingState,
26
27
  verifyHmac,
27
28
  } from '../engine/foundry-dispatch.js'
@@ -37,6 +38,7 @@ import {
37
38
  scheduleContent,
38
39
  unpublishContent,
39
40
  updateContentItem,
41
+ VerifiedMediaDurationConflictError,
40
42
  } from '../engine/publisher.js'
41
43
  import {
42
44
  createRevisionWithDelta,
@@ -55,6 +57,24 @@ import type { DispatchWebhookHook } from './webhooks.js'
55
57
 
56
58
  export type { DispatchWebhookHook }
57
59
 
60
+ type PodcastFoundryReason = 'missing_duration' | 'dispatch_failed' | null
61
+
62
+ interface PodcastFoundryAttempt {
63
+ result: DispatchResult | null
64
+ reason: PodcastFoundryReason
65
+ }
66
+
67
+ function verifiedMediaDurationConflictResponse(error: unknown): Response | null {
68
+ if (!(error instanceof VerifiedMediaDurationConflictError)) return null
69
+ return json(
70
+ {
71
+ error: 'Media duration conflicts with verified upload metadata',
72
+ reason: error.code,
73
+ },
74
+ 409,
75
+ )
76
+ }
77
+
58
78
  /**
59
79
  * Optional injected hook to dispatch article-takeaways generation to Foundry
60
80
  * (Hermes). The package never imports the Foundry glue — the site supplies this
@@ -260,7 +280,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
260
280
  content: z.object({
261
281
  script: z.string().default(''),
262
282
  videoId: z.string().min(1),
263
- durationSeconds: z.number().int().optional().nullable(),
283
+ durationSeconds: z.number().int().positive().optional().nullable(),
264
284
  thumbnailImageId: z.string().optional().nullable(),
265
285
  processingSourceUrl: z.string().url().optional().nullable(),
266
286
  processingSourceKind: z.string().min(1).optional().nullable(),
@@ -275,7 +295,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
275
295
  // the video `script` default-empty pattern above.
276
296
  transcript: z.string().default(''),
277
297
  audioR2Key: z.string().min(1),
278
- durationSeconds: z.number().int().optional().nullable(),
298
+ durationSeconds: z.number().int().positive().optional().nullable(),
279
299
  }),
280
300
  }),
281
301
  BaseSchema.extend({
@@ -334,7 +354,7 @@ const ArticleContentUpdateSchema = z.object({
334
354
  const VideoContentUpdateSchema = z.object({
335
355
  script: z.string().optional(),
336
356
  videoId: z.string().optional(),
337
- durationSeconds: z.number().int().optional().nullable(),
357
+ durationSeconds: z.number().int().positive().optional().nullable(),
338
358
  thumbnailImageId: z.string().optional().nullable(),
339
359
  processingSourceUrl: z.string().url().optional().nullable(),
340
360
  processingSourceKind: z.string().min(1).optional().nullable(),
@@ -343,7 +363,7 @@ const VideoContentUpdateSchema = z.object({
343
363
  const PodcastContentUpdateSchema = z.object({
344
364
  transcript: z.string().optional(),
345
365
  audioR2Key: z.string().optional(),
346
- durationSeconds: z.number().int().optional().nullable(),
366
+ durationSeconds: z.number().int().positive().optional().nullable(),
347
367
  })
348
368
 
349
369
  function getContentUpdateSchema(type: string) {
@@ -692,6 +712,35 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
692
712
  return dispatchToFoundry(ctx.db, contentId, resolved.hooks.foundryVideo)
693
713
  }
694
714
 
715
+ async function dispatchPodcastAfterSave(
716
+ ctx: RouteContext,
717
+ contentId: string,
718
+ ): Promise<PodcastFoundryAttempt> {
719
+ try {
720
+ return {
721
+ result: await maybeDispatchPodcastToFoundry(ctx, contentId),
722
+ reason: null,
723
+ }
724
+ } catch (error) {
725
+ if (error instanceof FoundryDispatchPrerequisiteError) {
726
+ return { result: null, reason: 'missing_duration' }
727
+ }
728
+ console.error('Podcast Foundry dispatch failed after content save', error)
729
+ return { result: null, reason: 'dispatch_failed' }
730
+ }
731
+ }
732
+
733
+ function missingDurationResponse(): Response {
734
+ return json(
735
+ {
736
+ error: 'Content requires a positive finite duration before dispatch',
737
+ processingQueued: false,
738
+ reason: 'missing_duration',
739
+ },
740
+ 409,
741
+ )
742
+ }
743
+
695
744
  /**
696
745
  * Fire the optional dispatchWebhook hook for a lifecycle transition.
697
746
  *
@@ -973,9 +1022,18 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
973
1022
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
974
1023
  }
975
1024
 
976
- const created = await createContent(ctx.db, parsed.data)
977
- const podcastDispatch =
978
- parsed.data.type === 'podcast' ? await maybeDispatchPodcastToFoundry(ctx, created.id) : null
1025
+ let created: Awaited<ReturnType<typeof createContent>>
1026
+ try {
1027
+ created = await createContent(ctx.db, parsed.data)
1028
+ } catch (error) {
1029
+ const conflict = verifiedMediaDurationConflictResponse(error)
1030
+ if (conflict) return conflict
1031
+ throw error
1032
+ }
1033
+ const podcastAttempt: PodcastFoundryAttempt =
1034
+ parsed.data.type === 'podcast'
1035
+ ? await dispatchPodcastAfterSave(ctx, created.id)
1036
+ : { result: null, reason: null }
979
1037
 
980
1038
  // Fire content.created webhook (lifecycle transition).
981
1039
  await fireWebhook(ctx, 'content.created', {
@@ -988,8 +1046,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
988
1046
  {
989
1047
  id: created.id,
990
1048
  slug: created.slug,
991
- podcastFoundryQueued: Boolean(podcastDispatch),
992
- podcastFoundryCorrelationId: podcastDispatch?.correlationId ?? null,
1049
+ podcastFoundryQueued: Boolean(podcastAttempt.result),
1050
+ podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
1051
+ podcastFoundryReason: podcastAttempt.reason,
993
1052
  },
994
1053
  201,
995
1054
  )
@@ -1118,10 +1177,19 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1118
1177
  }
1119
1178
 
1120
1179
  const priorSlug = existing.slug
1121
- const updated = await updateContentItem(ctx.db, id, parsed.data)
1180
+ let updated: Awaited<ReturnType<typeof updateContentItem>>
1181
+ try {
1182
+ updated = await updateContentItem(ctx.db, id, parsed.data)
1183
+ } catch (error) {
1184
+ const conflict = verifiedMediaDurationConflictResponse(error)
1185
+ if (conflict) return conflict
1186
+ throw error
1187
+ }
1122
1188
  if (!updated) return json({ error: 'Not found' }, 404)
1123
- const podcastDispatch =
1124
- existing.type === 'podcast' ? await maybeDispatchPodcastToFoundry(ctx, id) : null
1189
+ const podcastAttempt: PodcastFoundryAttempt =
1190
+ existing.type === 'podcast'
1191
+ ? await dispatchPodcastAfterSave(ctx, id)
1192
+ : { result: null, reason: null }
1125
1193
 
1126
1194
  // If the slug changed, record the old slug as a redirect (spec §8)
1127
1195
  // and drop any stale redirect row for the now-live slug — atomically,
@@ -1133,8 +1201,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1133
1201
  return json({
1134
1202
  id,
1135
1203
  slug: updated.slug,
1136
- podcastFoundryQueued: Boolean(podcastDispatch),
1137
- podcastFoundryCorrelationId: podcastDispatch?.correlationId ?? null,
1204
+ podcastFoundryQueued: Boolean(podcastAttempt.result),
1205
+ podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
1206
+ podcastFoundryReason: podcastAttempt.reason,
1138
1207
  })
1139
1208
  },
1140
1209
 
@@ -1206,6 +1275,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1206
1275
  try {
1207
1276
  podcastDispatch = await maybeDispatchPodcastToFoundry(ctx, id)
1208
1277
  } catch (error) {
1278
+ if (error instanceof FoundryDispatchPrerequisiteError) {
1279
+ return missingDurationResponse()
1280
+ }
1209
1281
  console.error('Podcast Foundry dispatch failed before publish', error)
1210
1282
  return json({ error: 'Foundry dispatch failed' }, 502)
1211
1283
  }
@@ -1488,6 +1560,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1488
1560
  202,
1489
1561
  )
1490
1562
  } catch (error) {
1563
+ if (error instanceof FoundryDispatchPrerequisiteError) {
1564
+ return missingDurationResponse()
1565
+ }
1491
1566
  console.error('Foundry dispatch failed', error)
1492
1567
  return json({ error: 'Foundry dispatch failed' }, 502)
1493
1568
  }
@@ -1587,9 +1662,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1587
1662
  })
1588
1663
 
1589
1664
  // Fire video.processed / video.failed webhook on terminal Foundry states.
1590
- if (nextState === 'ready') {
1665
+ if (ok && nextState === 'ready') {
1591
1666
  await fireWebhook(ctx, 'video.processed', { correlationId, eventId, eventKind })
1592
- } else if (nextState === 'failed') {
1667
+ } else if (ok && nextState === 'failed') {
1593
1668
  await fireWebhook(ctx, 'video.failed', {
1594
1669
  correlationId,
1595
1670
  eventId,
@@ -64,6 +64,7 @@ export interface R2MultipartUpload {
64
64
 
65
65
  export interface R2HeadResult {
66
66
  size: number
67
+ customMetadata?: Record<string, string>
67
68
  }
68
69
 
69
70
  export interface R2BucketLike {
@@ -116,6 +116,7 @@ const initVideoUploadSchema = z.object({
116
116
  fileName: z.string().min(1),
117
117
  contentType: z.string().optional().nullable(),
118
118
  sizeBytes: z.number().int().positive().max(MAX_EDITOR_VIDEO_SOURCE_BYTES),
119
+ durationSeconds: z.number().int().positive(),
119
120
  })
120
121
 
121
122
  const uploadedPartSchema = z.object({
@@ -130,6 +131,7 @@ const completeVideoUploadSchema = z.object({
130
131
  fileName: z.string().min(1),
131
132
  contentType: z.string().optional().nullable(),
132
133
  sizeBytes: z.number().int().positive().max(MAX_EDITOR_VIDEO_SOURCE_BYTES),
134
+ durationSeconds: z.number().int().positive(),
133
135
  parts: z.array(uploadedPartSchema).min(1).max(10_000),
134
136
  })
135
137
 
@@ -143,6 +145,7 @@ const initPodcastUploadSchema = z.object({
143
145
  fileName: z.string().min(1),
144
146
  contentType: z.string().optional().nullable(),
145
147
  sizeBytes: z.number().int().positive().max(MAX_EDITOR_AUDIO_SOURCE_BYTES),
148
+ durationSeconds: z.number().int().positive(),
146
149
  })
147
150
 
148
151
  const completePodcastUploadSchema = z.object({
@@ -152,6 +155,7 @@ const completePodcastUploadSchema = z.object({
152
155
  fileName: z.string().min(1),
153
156
  contentType: z.string().optional().nullable(),
154
157
  sizeBytes: z.number().int().positive().max(MAX_EDITOR_AUDIO_SOURCE_BYTES),
158
+ durationSeconds: z.number().int().positive(),
155
159
  parts: z.array(uploadedPartSchema).min(1).max(10_000),
156
160
  })
157
161
 
@@ -490,6 +494,7 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
490
494
  cacheControl: 'public, max-age=31536000, immutable',
491
495
  },
492
496
  customMetadata: {
497
+ durationSeconds: String(parsed.data.durationSeconds),
493
498
  originalFilename: parsed.data.fileName,
494
499
  sizeBytes: String(parsed.data.sizeBytes),
495
500
  source: 'cms',
@@ -555,6 +560,9 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
555
560
  if (typeof bucket.resumeMultipartUpload !== 'function') {
556
561
  return json({ error: 'Missing configuration' }, 500)
557
562
  }
563
+ if (typeof bucket.head !== 'function') {
564
+ return json({ error: 'R2 metadata verification is unavailable' }, 500)
565
+ }
558
566
 
559
567
  const parsed = completePodcastUploadSchema.safeParse(await readJson(ctx))
560
568
  if (!parsed.success) return json({ error: 'Invalid podcast upload completion request' }, 400)
@@ -599,25 +607,26 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
599
607
  const upload = bucket.resumeMultipartUpload(parsed.data.key, parsed.data.uploadId)
600
608
  await upload.complete(parts)
601
609
 
602
- let sizeVerification: 'verified' | 'unsupported' = 'unsupported'
603
- if (typeof bucket.head === 'function') {
604
- const object = await bucket.head(parsed.data.key)
605
- if (
606
- !object ||
607
- object.size !== parsed.data.sizeBytes ||
608
- object.size > MAX_EDITOR_AUDIO_SOURCE_BYTES
609
- ) {
610
- await deletePodcastAudioAsset(ctx, bucket, parsed.data.key)
611
- return json(
612
- {
613
- error: 'Uploaded podcast audio size did not match the expected size',
614
- expectedSizeBytes: parsed.data.sizeBytes,
615
- actualSizeBytes: object?.size ?? null,
616
- },
617
- 400,
618
- )
619
- }
620
- sizeVerification = 'verified'
610
+ const object = await bucket.head(parsed.data.key)
611
+ if (
612
+ !object ||
613
+ object.size !== parsed.data.sizeBytes ||
614
+ object.customMetadata?.sizeBytes !== String(parsed.data.sizeBytes) ||
615
+ object.size > MAX_EDITOR_AUDIO_SOURCE_BYTES
616
+ ) {
617
+ await deletePodcastAudioAsset(ctx, bucket, parsed.data.key)
618
+ return json(
619
+ {
620
+ error: 'Uploaded podcast audio size did not match the expected size',
621
+ expectedSizeBytes: parsed.data.sizeBytes,
622
+ actualSizeBytes: object?.size ?? null,
623
+ },
624
+ 400,
625
+ )
626
+ }
627
+ if (object.customMetadata?.durationSeconds !== String(parsed.data.durationSeconds)) {
628
+ await deletePodcastAudioAsset(ctx, bucket, parsed.data.key)
629
+ return json({ error: 'Uploaded podcast duration did not match the multipart upload' }, 400)
621
630
  }
622
631
 
623
632
  try {
@@ -635,6 +644,7 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
635
644
  height: null,
636
645
  variantsJson: JSON.stringify({
637
646
  contentType,
647
+ durationSeconds: parsed.data.durationSeconds,
638
648
  sizeBytes: parsed.data.sizeBytes,
639
649
  slug: slugify(parsed.data.slug) || 'podcast',
640
650
  }),
@@ -655,8 +665,9 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
655
665
  key: parsed.data.key,
656
666
  publicUrl,
657
667
  url: publicUrl,
658
- sizeVerification,
668
+ sizeVerification: 'verified',
659
669
  sizeBytes: parsed.data.sizeBytes,
670
+ durationSeconds: parsed.data.durationSeconds,
660
671
  })
661
672
  },
662
673
 
@@ -720,6 +731,7 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
720
731
  const upload = await bucket.createMultipartUpload(key, {
721
732
  httpMetadata: { contentType, cacheControl: 'public, max-age=3600' },
722
733
  customMetadata: {
734
+ durationSeconds: String(parsed.data.durationSeconds),
723
735
  originalFilename: parsed.data.fileName,
724
736
  sizeBytes: String(parsed.data.sizeBytes),
725
737
  source: 'cms',
@@ -786,6 +798,9 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
786
798
  if (typeof bucket.resumeMultipartUpload !== 'function') {
787
799
  return json({ error: 'Missing configuration' }, 500)
788
800
  }
801
+ if (typeof bucket.head !== 'function') {
802
+ return json({ error: 'R2 metadata verification is unavailable' }, 500)
803
+ }
789
804
 
790
805
  const parsed = completeVideoUploadSchema.safeParse(await readJson(ctx))
791
806
  if (!parsed.success) return json({ error: 'Invalid video upload completion request' }, 400)
@@ -844,25 +859,26 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
844
859
  publicDomain: domain,
845
860
  })
846
861
 
847
- let sizeVerification: 'verified' | 'unsupported' = 'unsupported'
848
- if (typeof bucket.head === 'function') {
849
- const object = await bucket.head(parsed.data.key)
850
- if (
851
- !object ||
852
- object.size !== parsed.data.sizeBytes ||
853
- object.size > MAX_EDITOR_VIDEO_SOURCE_BYTES
854
- ) {
855
- await deleteStagedVideoSource(ctx.db, bucket, sourceUrl, domain)
856
- return json(
857
- {
858
- error: 'Uploaded video source size did not match the expected size',
859
- expectedSizeBytes: parsed.data.sizeBytes,
860
- actualSizeBytes: object?.size ?? null,
861
- },
862
- 400,
863
- )
864
- }
865
- sizeVerification = 'verified'
862
+ const object = await bucket.head(parsed.data.key)
863
+ if (
864
+ !object ||
865
+ object.size !== parsed.data.sizeBytes ||
866
+ object.customMetadata?.sizeBytes !== String(parsed.data.sizeBytes) ||
867
+ object.size > MAX_EDITOR_VIDEO_SOURCE_BYTES
868
+ ) {
869
+ await deleteStagedVideoSource(ctx.db, bucket, sourceUrl, domain)
870
+ return json(
871
+ {
872
+ error: 'Uploaded video source size did not match the expected size',
873
+ expectedSizeBytes: parsed.data.sizeBytes,
874
+ actualSizeBytes: object?.size ?? null,
875
+ },
876
+ 400,
877
+ )
878
+ }
879
+ if (object.customMetadata?.durationSeconds !== String(parsed.data.durationSeconds)) {
880
+ await deleteStagedVideoSource(ctx.db, bucket, sourceUrl, domain)
881
+ return json({ error: 'Uploaded video duration did not match the multipart upload' }, 400)
866
882
  }
867
883
 
868
884
  try {
@@ -878,7 +894,12 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
878
894
  url: sourceUrl,
879
895
  width: null,
880
896
  height: null,
881
- variantsJson: JSON.stringify({ contentType, sizeBytes: parsed.data.sizeBytes, videoId }),
897
+ variantsJson: JSON.stringify({
898
+ contentType,
899
+ durationSeconds: parsed.data.durationSeconds,
900
+ sizeBytes: parsed.data.sizeBytes,
901
+ videoId,
902
+ }),
882
903
  r2Binding: mediaR2Binding,
883
904
  })
884
905
  } catch (error) {
@@ -898,9 +919,10 @@ export function createMediaRoutes(config: CmsRouteConfig): MediaRouteHandlers {
898
919
  processingStatus: 'processing',
899
920
  sourceUrl,
900
921
  sourceKind: 'http_url',
901
- sizeVerification,
922
+ sizeVerification: 'verified',
902
923
  videoId,
903
924
  sizeBytes: parsed.data.sizeBytes,
925
+ durationSeconds: parsed.data.durationSeconds,
904
926
  })
905
927
  },
906
928