@growth-labs/cms 0.8.7 → 0.8.14

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 (56) hide show
  1. package/dist/engine/fronts-publish.d.ts.map +1 -1
  2. package/dist/engine/fronts-publish.js +44 -8
  3. package/dist/engine/fronts-publish.js.map +1 -1
  4. package/dist/engine/publisher.d.ts +3 -1
  5. package/dist/engine/publisher.d.ts.map +1 -1
  6. package/dist/engine/publisher.js +21 -11
  7. package/dist/engine/publisher.js.map +1 -1
  8. package/dist/migration-vendor.d.ts.map +1 -1
  9. package/dist/migration-vendor.js +129 -12
  10. package/dist/migration-vendor.js.map +1 -1
  11. package/dist/routes/content.d.ts +1 -0
  12. package/dist/routes/content.d.ts.map +1 -1
  13. package/dist/routes/content.js +166 -10
  14. package/dist/routes/content.js.map +1 -1
  15. package/dist/ui/api-error.d.ts +17 -0
  16. package/dist/ui/api-error.d.ts.map +1 -0
  17. package/dist/ui/api-error.js +46 -0
  18. package/dist/ui/api-error.js.map +1 -0
  19. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  20. package/dist/ui/editor/ContentForm.js +5 -4
  21. package/dist/ui/editor/ContentForm.js.map +1 -1
  22. package/dist/ui/editor/editor-media-upload.d.ts.map +1 -1
  23. package/dist/ui/editor/editor-media-upload.js +2 -1
  24. package/dist/ui/editor/editor-media-upload.js.map +1 -1
  25. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  26. package/dist/ui/inspector/PublishTab.js +2 -1
  27. package/dist/ui/inspector/PublishTab.js.map +1 -1
  28. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  29. package/dist/ui/screens/LibraryScreen.js +4 -3
  30. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  31. package/dist/ui/screens/SettingsScreen.d.ts.map +1 -1
  32. package/dist/ui/screens/SettingsScreen.js +14 -13
  33. package/dist/ui/screens/SettingsScreen.js.map +1 -1
  34. package/dist/ui/screens/TopicsScreen.d.ts.map +1 -1
  35. package/dist/ui/screens/TopicsScreen.js +3 -2
  36. package/dist/ui/screens/TopicsScreen.js.map +1 -1
  37. package/dist/ui/screens/author-recovery.d.ts.map +1 -1
  38. package/dist/ui/screens/author-recovery.js +2 -1
  39. package/dist/ui/screens/author-recovery.js.map +1 -1
  40. package/dist/ui/screens/media-upload.d.ts.map +1 -1
  41. package/dist/ui/screens/media-upload.js +2 -1
  42. package/dist/ui/screens/media-upload.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/engine/fronts-publish.ts +45 -6
  45. package/src/engine/publisher.ts +24 -11
  46. package/src/migration-vendor.ts +146 -14
  47. package/src/routes/content.ts +188 -18
  48. package/src/ui/api-error.ts +55 -0
  49. package/src/ui/editor/ContentForm.tsx +8 -13
  50. package/src/ui/editor/editor-media-upload.ts +2 -1
  51. package/src/ui/inspector/PublishTab.tsx +2 -1
  52. package/src/ui/screens/LibraryScreen.tsx +4 -3
  53. package/src/ui/screens/SettingsScreen.tsx +14 -13
  54. package/src/ui/screens/TopicsScreen.tsx +3 -2
  55. package/src/ui/screens/author-recovery.ts +3 -1
  56. package/src/ui/screens/media-upload.ts +3 -2
@@ -41,7 +41,7 @@ import {
41
41
  PublicationCurrentSnapshotError,
42
42
  snapshotRecord,
43
43
  } from './publication-current-guard.js'
44
- import { getContentSnapshot } from './publisher.js'
44
+ import { getContentSnapshot, PublishContentValidationError } from './publisher.js'
45
45
  import { getRevision } from './revisions.js'
46
46
 
47
47
  /** The five lifecycle states foundryd reports for a Fronts publish intent. */
@@ -297,6 +297,10 @@ export async function applyFrontsPublishCallback(
297
297
  let published = item.status === 'published'
298
298
  let attemptId: string | null = null
299
299
  let ledgerError: string | null = null
300
+ // A body that fails pre-publish validation will fail again on every retry
301
+ // until an editor changes it. Recording that distinctly lets the marker say
302
+ // `failed` instead of leaving the intent to be re-admitted forever.
303
+ let publishRejectedDeterministically = false
300
304
  let committedDekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
301
305
  let committedPublishedItem: FrontsPublishCallbackResult['publishedItem'] = null
302
306
  const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
@@ -561,7 +565,19 @@ export async function applyFrontsPublishCallback(
561
565
  'Scheduled content or media changed before publication.',
562
566
  )
563
567
  }
564
- ledgerError = error instanceof Error ? error.message : String(error)
568
+ publishRejectedDeterministically = error instanceof PublishContentValidationError
569
+ // `error.message` is the same generic sentence for every body
570
+ // rejection ("Article body failed pre-publish validation"), which
571
+ // tells an operator that something is wrong but never what. The
572
+ // guard already computed the violated codes; carrying its summary
573
+ // onto the event row is the difference between reading the answer
574
+ // and re-running the validator by hand against the stored body.
575
+ ledgerError =
576
+ error instanceof PublishContentValidationError
577
+ ? error.summary
578
+ : error instanceof Error
579
+ ? error.message
580
+ : String(error)
565
581
  }
566
582
  }
567
583
  }
@@ -578,13 +594,20 @@ export async function applyFrontsPublishCallback(
578
594
  )
579
595
  .bind(input.contentId, input.version ?? null, input.version ?? null)
580
596
  .first<{ latest: number | null }>()
597
+ // A `live` whose publication was refused for a reason no retry can change
598
+ // records `failed`, not `live`: the editor has to act, and `failed` is what
599
+ // makes that visible in Masthead and to foundryd's late evaluator.
600
+ const markerStatus: FrontsPublishStatus =
601
+ publishRejectedDeterministically && input.status === 'live' ? 'failed' : input.status
581
602
  if (
603
+ publishRejectedDeterministically ||
582
604
  shouldApplyFrontsMarker(
583
605
  item.status,
584
606
  item.fronts_publish_status,
585
607
  input.status,
586
608
  incomingOccurredAt,
587
609
  latestSeenRow?.latest ?? null,
610
+ published,
588
611
  )
589
612
  ) {
590
613
  const marker = await db
@@ -593,7 +616,7 @@ export async function applyFrontsPublishCallback(
593
616
  WHERE id = ? AND canonical_version = ? AND status = ? AND publish_at IS ? AND deleted_at IS NULL`,
594
617
  )
595
618
  .bind(
596
- input.status,
619
+ markerStatus,
597
620
  input.contentId,
598
621
  item.canonical_version,
599
622
  published ? 'published' : item.status,
@@ -752,8 +775,23 @@ async function readStoredOutcome(
752
775
 
753
776
  /**
754
777
  * Whether an incoming callback may overwrite `content_items.fronts_publish_status`.
755
- * `live` is the authoritative terminal-success marker (always reflected, never
756
- * downgraded); older-by-occurred_at callbacks may not regress a newer marker.
778
+ * `live` is the authoritative terminal-success marker (never downgraded once
779
+ * earned); older-by-occurred_at callbacks may not regress a newer marker.
780
+ *
781
+ * `live` is only EARNED by a committed publication. A `live` callback whose
782
+ * publish did not commit -- `publishOne` threw and left `ledgerError` set, or
783
+ * the callback carried no usable receipt so the publish block never ran --
784
+ * must not stamp success onto a row that is still `scheduled`. Doing so cost a
785
+ * missed publish on 2026-09-15 (`can-minilateralism-solve-europes-defense-crisis`):
786
+ * the marker read `live` while the item had zero revisions and zero publication
787
+ * attempts, and both foundryd's admission (anything past `queued` is the
788
+ * executor's) and its unadmitted-late evaluator (`live` means done) then treated
789
+ * the item as finished, so it was never retried and never paged.
790
+ *
791
+ * Leaving the marker alone is what keeps the item recoverable: it stays
792
+ * `queued`/`publishing`, so the next reconciler tick re-admits it and the late
793
+ * evaluator still sees an unfinished intent. The failure itself is not lost --
794
+ * it is recorded on the `fronts_publish_events` row with its `ledgerError`.
757
795
  */
758
796
  function shouldApplyFrontsMarker(
759
797
  itemStatus: string,
@@ -761,8 +799,9 @@ function shouldApplyFrontsMarker(
761
799
  incomingStatus: FrontsPublishStatus,
762
800
  incomingOccurredAt: number | null,
763
801
  latestSeenOccurredAt: number | null,
802
+ published: boolean,
764
803
  ): boolean {
765
- if (incomingStatus === 'live') return true
804
+ if (incomingStatus === 'live') return published
766
805
  if (itemStatus === 'published' || currentMarker === 'live') return false
767
806
  if (
768
807
  incomingOccurredAt !== null &&
@@ -799,7 +799,7 @@ export async function updateContentItem(
799
799
  const current = await db
800
800
  .prepare(
801
801
  `SELECT script, video_id, duration_seconds, thumbnail_image_id,
802
- processing_source_url, processing_source_kind
802
+ processing_source_url, processing_source_kind, processing_trigger_token
803
803
  FROM video_content
804
804
  WHERE content_id = ?
805
805
  LIMIT 1`,
@@ -812,6 +812,7 @@ export async function updateContentItem(
812
812
  thumbnail_image_id: string | null
813
813
  processing_source_url: string | null
814
814
  processing_source_kind: string | null
815
+ processing_trigger_token: string | null
815
816
  }>()
816
817
 
817
818
  if (current) {
@@ -836,14 +837,26 @@ export async function updateContentItem(
836
837
  sourceUrl,
837
838
  durationProvided ? videoContent.durationSeconds : undefined,
838
839
  )
840
+ const mediaChanged =
841
+ sourceChanged ||
842
+ resolvedDuration !== current.duration_seconds ||
843
+ (input.slug !== undefined && input.slug !== existing.slug) ||
844
+ (input.visibility !== undefined && input.visibility !== existing.visibility)
845
+ const requestedVideoId = hasOwn(videoContent, 'videoId')
846
+ ? (videoContent.videoId ?? current.video_id)
847
+ : current.video_id
848
+ // A captured source pins its media identity. The editor form re-derives
849
+ // videoId from the title on every save of a new video, so a title edit
850
+ // alone used to reset the capture; nothing re-captured it, and the video
851
+ // stayed scheduled but was never prepared (Fronts, 2026-09-15..17).
852
+ const videoId =
853
+ current.processing_trigger_token && !mediaChanged ? current.video_id : requestedVideoId
839
854
 
840
855
  videoUpdatePlan = {
841
856
  script: hasOwn(videoContent, 'script')
842
857
  ? (videoContent.script ?? current.script)
843
858
  : current.script,
844
- videoId: hasOwn(videoContent, 'videoId')
845
- ? (videoContent.videoId ?? current.video_id)
846
- : current.video_id,
859
+ videoId,
847
860
  durationSeconds:
848
861
  durationProvided || sourceChanged
849
862
  ? resolvedDuration
@@ -853,12 +866,7 @@ export async function updateContentItem(
853
866
  : current.thumbnail_image_id,
854
867
  sourceUrl,
855
868
  sourceKind,
856
- sourceChanged:
857
- sourceChanged ||
858
- resolvedDuration !== current.duration_seconds ||
859
- (input.slug !== undefined && input.slug !== existing.slug) ||
860
- (input.visibility !== undefined && input.visibility !== existing.visibility) ||
861
- (hasOwn(videoContent, 'videoId') && videoContent.videoId !== current.video_id),
869
+ sourceChanged: mediaChanged || videoId !== current.video_id,
862
870
  }
863
871
  } else {
864
872
  videoUpdatePlan = null
@@ -1458,6 +1466,7 @@ export async function evaluateContentBodyForPublish(
1458
1466
  db: D1Database,
1459
1467
  id: string,
1460
1468
  source: string,
1469
+ options?: { requireBody?: boolean },
1461
1470
  ): Promise<PublishGuardOutcome | null> {
1462
1471
  const row = await db
1463
1472
  .prepare(
@@ -1489,7 +1498,11 @@ export async function evaluateContentBodyForPublish(
1489
1498
  {
1490
1499
  source,
1491
1500
  contentId: id,
1492
- requireBody: true,
1501
+ // Absence and corruption are different failures. Publishing requires
1502
+ // a body; SCHEDULING does not -- an editor may legitimately claim a
1503
+ // slot before the piece is finished -- but neither may carry a body
1504
+ // that is already broken.
1505
+ requireBody: options?.requireBody ?? true,
1493
1506
  // #329: source-section defects block at publish, warn on save.
1494
1507
  blockSourceSectionDefects: true,
1495
1508
  },
@@ -119,26 +119,149 @@ function sequencePrefix(sequence: number): string {
119
119
  return String(sequence).padStart(4, '0')
120
120
  }
121
121
 
122
+ function targetSequence(target: string): number {
123
+ return Number.parseInt(target.slice(0, 4), 10)
124
+ }
125
+
126
+ function managedSuffix(source: string): string {
127
+ return `_growth_labs_cms_${source.replace(/^\d{4}_/, '')}`
128
+ }
129
+
130
+ /**
131
+ * Sequences already spoken for in the consumer's migrations directory.
132
+ *
133
+ * Wrangler accepts one `migrations_dir`, so a consumer's own analytics, SEO or
134
+ * product migrations are interleaved with the vendored package ones. Those
135
+ * numbers are not ours to take.
136
+ */
137
+ async function occupiedSequences(migrationsDir: string): Promise<Set<number>> {
138
+ const names = await readdir(migrationsDir)
139
+ const taken = new Set<number>()
140
+ for (const name of names) {
141
+ if (!name.endsWith('.sql')) continue
142
+ const sequence = targetSequence(name)
143
+ if (Number.isSafeInteger(sequence)) taken.add(sequence)
144
+ }
145
+ return taken
146
+ }
147
+
148
+ interface PlanContext {
149
+ /** Recorded source -> target, the authority for anything already vendored. */
150
+ recorded: Map<string, CmsMigrationManifestEntry>
151
+ /** Existing consumer files, for sequence avoidance and hash adoption. */
152
+ migrationsDir?: string
153
+ }
154
+
155
+ /**
156
+ * Resolve each package migration to the consumer filename it must occupy.
157
+ *
158
+ * The hard invariant: a migration this consumer has ALREADY vendored keeps its
159
+ * exact filename forever. D1's `d1_migrations` ledger keys off that name, so
160
+ * renumbering an applied migration would re-run it. Every branch below either
161
+ * reuses a name or allocates an unused one; none ever rewrites an assigned one.
162
+ *
163
+ * Resolution order per source:
164
+ * 1. recorded in the receipt -> reuse that target verbatim;
165
+ * 2. an on-disk file with our managed suffix whose bytes hash identical
166
+ * -> adopt it (reconciles migrations vendored by hand before the receipt
167
+ * existed, without creating a duplicate that would apply the same SQL
168
+ * twice);
169
+ * 3. otherwise -> the next sequence above everything already assigned or
170
+ * occupied, skipping consumer-owned numbers.
171
+ */
122
172
  async function planMigrations(
123
173
  sourceDir: string,
124
174
  startSequence: number,
175
+ context: PlanContext = { recorded: new Map() },
125
176
  ): Promise<PlannedMigration[]> {
126
177
  assertStartSequence(startSequence)
127
178
  const sourceNames = (await readdir(sourceDir)).filter((name) => MIGRATION_FILE.test(name)).sort()
128
179
  if (sourceNames.length === 0) throw new Error('installed CMS package has no migration SQL')
129
- if (startSequence + sourceNames.length - 1 > 9999) {
130
- throw new Error('reserved CMS migration sequence exceeds 9999')
180
+
181
+ const occupied = context.migrationsDir
182
+ ? await occupiedSequences(context.migrationsDir)
183
+ : new Set<number>()
184
+ const existingNames = context.migrationsDir ? await readdir(context.migrationsDir) : []
185
+ const assigned = new Set<number>()
186
+ let highWater = startSequence - 1
187
+ for (const sequence of occupied) {
188
+ if (sequence > highWater) highWater = sequence
131
189
  }
132
190
 
133
- return Promise.all(
134
- sourceNames.map(async (source, index) => {
135
- const sequence = startSequence + index
136
- const suffix = source.replace(/^\d{4}_/, '')
137
- const target = `${sequencePrefix(sequence)}_growth_labs_cms_${suffix}`
138
- const bytes = new Uint8Array(await readFile(join(sourceDir, source)))
139
- return { source, target, sequence, sha256: await sha256(bytes), bytes }
140
- }),
141
- )
191
+ const planned: PlannedMigration[] = []
192
+ for (const source of sourceNames) {
193
+ const bytes = new Uint8Array(await readFile(join(sourceDir, source)))
194
+ const hash = await sha256(bytes)
195
+ const suffix = managedSuffix(source)
196
+
197
+ const recorded = context.recorded.get(source)
198
+ if (recorded) {
199
+ const sequence = targetSequence(recorded.target)
200
+ assigned.add(sequence)
201
+ if (sequence > highWater) highWater = sequence
202
+ planned.push({ source, target: recorded.target, sequence, sha256: hash, bytes })
203
+ continue
204
+ }
205
+
206
+ const adopted = await findAdoptableTarget(
207
+ context.migrationsDir,
208
+ existingNames,
209
+ suffix,
210
+ hash,
211
+ assigned,
212
+ )
213
+ if (adopted) {
214
+ const sequence = targetSequence(adopted)
215
+ assigned.add(sequence)
216
+ if (sequence > highWater) highWater = sequence
217
+ planned.push({ source, target: adopted, sequence, sha256: hash, bytes })
218
+ continue
219
+ }
220
+
221
+ let sequence = Math.max(startSequence, highWater + 1)
222
+ while (occupied.has(sequence) || assigned.has(sequence)) sequence += 1
223
+ if (sequence > 9999) throw new Error('reserved CMS migration sequence exceeds 9999')
224
+ assigned.add(sequence)
225
+ highWater = sequence
226
+ planned.push({
227
+ source,
228
+ target: `${sequencePrefix(sequence)}${suffix}`,
229
+ sequence,
230
+ sha256: hash,
231
+ bytes,
232
+ })
233
+ }
234
+
235
+ return planned
236
+ }
237
+
238
+ /**
239
+ * An existing consumer file that IS this package migration, byte for byte.
240
+ *
241
+ * Content-addressed on purpose: adopting by filename alone would let an
242
+ * unrelated file with a colliding suffix be claimed as ours.
243
+ */
244
+ async function findAdoptableTarget(
245
+ migrationsDir: string | undefined,
246
+ existingNames: string[],
247
+ suffix: string,
248
+ hash: string,
249
+ assigned: Set<number>,
250
+ ): Promise<string | null> {
251
+ if (!migrationsDir) return null
252
+ for (const name of existingNames.slice().sort()) {
253
+ if (!name.endsWith('.sql') || !name.endsWith(`${suffix}`)) continue
254
+ const sequence = targetSequence(name)
255
+ if (!Number.isSafeInteger(sequence) || assigned.has(sequence)) continue
256
+ let existingHash: string
257
+ try {
258
+ existingHash = await sha256(new Uint8Array(await readFile(join(migrationsDir, name))))
259
+ } catch {
260
+ continue
261
+ }
262
+ if (existingHash === hash) return name
263
+ }
264
+ return null
142
265
  }
143
266
 
144
267
  function manifestFor(
@@ -365,9 +488,15 @@ export async function syncCmsMigrations(
365
488
  const manifestPath = await resolveManifestPath(migrationsDir, options.manifestPath)
366
489
  return withManifestLock(manifestPath, async () => {
367
490
  const contract = await packageContract()
368
- const plan = await planMigrations(contract.sourceDir, options.startSequence)
369
- const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
370
491
  const currentManifest = await readManifest(manifestPath)
492
+ // The receipt is the authority for anything already vendored: plan with it
493
+ // so recorded migrations keep their exact filenames and only genuinely new
494
+ // ones are allocated a sequence.
495
+ const plan = await planMigrations(contract.sourceDir, options.startSequence, {
496
+ recorded: new Map((currentManifest?.files ?? []).map((entry) => [entry.source, entry])),
497
+ migrationsDir,
498
+ })
499
+ const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
371
500
  assertManifestCanAdvance(currentManifest, nextManifest)
372
501
  await assertNoSequenceCollisions(migrationsDir, plan)
373
502
  const preflight = await verifyTargets(migrationsDir, plan, true)
@@ -424,7 +553,10 @@ export async function verifyCmsMigrations(
424
553
  `CMS migration manifest version ${manifest.packageVersion} does not match installed ${contract.packageVersion}`,
425
554
  )
426
555
  }
427
- const plan = await planMigrations(contract.sourceDir, manifest.startSequence)
556
+ const plan = await planMigrations(contract.sourceDir, manifest.startSequence, {
557
+ recorded: new Map(manifest.files.map((entry) => [entry.source, entry])),
558
+ migrationsDir,
559
+ })
428
560
  const expected = manifestFor(contract.packageVersion, manifest.startSequence, plan)
429
561
  if (
430
562
  expected.files.length !== manifest.files.length ||
@@ -391,23 +391,49 @@ const ContentMetadataSchema = z
391
391
  })
392
392
  .transform((metadata) => metadata as ContentMetadata)
393
393
 
394
- // Known Nextcloud share pages are HTML, never ingestable media. The editor
395
- // must save the direct-download URL so the existing provider validates bytes.
396
- const MediaSourceUrlSchema = z
397
- .string()
398
- .url()
399
- .refine(
400
- (value) => {
401
- let url: URL
402
- try {
403
- url = new URL(value)
404
- } catch {
405
- return false
406
- }
407
- return !(url.hostname === 'drive.fulcrum-portal.com' && /^\/s\/[^/]+\/?$/.test(url.pathname))
408
- },
409
- { message: 'Use the Nextcloud direct-download URL ending in /download, not its share page.' },
410
- )
394
+ // A Nextcloud share PAGE is HTML, never ingestable media, so the stored source
395
+ // must be the direct-download URL. Editors paste the share link -- that is what
396
+ // Nextcloud's "Copy link" button yields -- so normalise it here instead of
397
+ // refusing the save. Refusing it stranded every video upload from the 0.8.5
398
+ // deploy until 0.8.8, because the masthead only rendered the top-level
399
+ // 'Invalid request' and never the field message telling them what to change.
400
+ //
401
+ // Only the onboarded share host is claimed, and only the share-link shape:
402
+ // https, no credentials, default port, /s/<token> with an optional /download.
403
+ // Nextcloud's folder-listing "Copy link" appends ?dir=/ to a single-file share;
404
+ // it carries nothing the download route needs, so it is dropped. Anything else
405
+ // -- another host, another path, a non-URL -- is returned untouched for the
406
+ // normal URL validation to accept or reject on its own terms.
407
+ const NEXTCLOUD_SHARE_HOSTS = ['drive.fulcrum-portal.com']
408
+ const NEXTCLOUD_SHARE_PATH_RE = /^\/s\/[A-Za-z0-9_-]+(?:\/download)?\/?$/
409
+
410
+ export function normaliseNextcloudShareUrl(value: string): string {
411
+ let url: URL
412
+ try {
413
+ url = new URL(value)
414
+ } catch {
415
+ return value
416
+ }
417
+ if (
418
+ url.protocol !== 'https:' ||
419
+ url.username ||
420
+ url.password ||
421
+ (url.port && url.port !== '443') ||
422
+ !NEXTCLOUD_SHARE_HOSTS.includes(url.hostname.toLowerCase().replace(/\.$/, '')) ||
423
+ !NEXTCLOUD_SHARE_PATH_RE.test(url.pathname)
424
+ ) {
425
+ return value
426
+ }
427
+ if (url.searchParams.get('dir') !== null && url.searchParams.size === 1) {
428
+ url.search = ''
429
+ }
430
+ if (!/\/download\/?$/.test(url.pathname)) {
431
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/download`
432
+ }
433
+ return url.toString()
434
+ }
435
+
436
+ const MediaSourceUrlSchema = z.string().url().transform(normaliseNextcloudShareUrl)
411
437
 
412
438
  function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
413
439
  const category = slugEnum(resolved.primaryCategorySlugs)
@@ -1114,6 +1140,72 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1114
1140
  }
1115
1141
  }
1116
1142
 
1143
+ /**
1144
+ * The video sibling of maybeDispatchPodcastToFoundry: capture a sourced video
1145
+ * that holds no capture. A row with no trigger token is invisible to the
1146
+ * producer whatever its state says (trash and restore clears the token and
1147
+ * leaves `queued`), so only `ready` media and `failed` — the editor's Re-run
1148
+ * decision — are left alone. An archived item is inactive to the dispatcher;
1149
+ * it is captured once scheduling has made it active again.
1150
+ * The editor form captures after a save too, but it de-dupes on local state; a
1151
+ * later save that reset the capture left the video scheduled and never
1152
+ * prepared, with nothing to notice (Fronts, 2026-09-15..17).
1153
+ */
1154
+ async function maybeCaptureVideoSource(
1155
+ ctx: RouteContext,
1156
+ contentId: string,
1157
+ ): Promise<DispatchResult | null> {
1158
+ const hook = resolved.hooks.foundryVideo
1159
+ if (!hook) return null
1160
+ const readCapture = () =>
1161
+ ctx.db
1162
+ .prepare(
1163
+ `SELECT vc.processing_source_url, vc.processing_trigger_token, vc.processing_state,
1164
+ vc.hls_ready, ci.status
1165
+ FROM video_content vc JOIN content_items ci ON ci.id = vc.content_id
1166
+ WHERE vc.content_id = ?
1167
+ LIMIT 1`,
1168
+ )
1169
+ .bind(contentId)
1170
+ .first<{
1171
+ processing_source_url: string | null
1172
+ processing_trigger_token: string | null
1173
+ processing_state: string | null
1174
+ hls_ready: number | null
1175
+ status: string
1176
+ }>()
1177
+ const row = await readCapture()
1178
+ if (!row?.processing_source_url?.trim()) return null
1179
+ if (row.processing_trigger_token?.trim()) return null
1180
+ if (row.status === 'archived') return null
1181
+ if (row.hls_ready === 1) return null
1182
+ if (row.processing_state === 'ready' || row.processing_state === 'failed') return null
1183
+ try {
1184
+ return await dispatchToFoundry(ctx.db, contentId, hook, { ifUncaptured: true })
1185
+ } catch (error) {
1186
+ // The editor form fires its own capture after every save. Losing that
1187
+ // race fails this call's compare-and-set, and the capture exists.
1188
+ const raced = await readCapture()
1189
+ if (raced?.processing_trigger_token?.trim()) return null
1190
+ throw error
1191
+ }
1192
+ }
1193
+
1194
+ async function captureScheduledVideoAfterSave(
1195
+ ctx: RouteContext,
1196
+ contentId: string,
1197
+ ): Promise<PodcastFoundryAttempt> {
1198
+ try {
1199
+ return { result: await maybeCaptureVideoSource(ctx, contentId), reason: null }
1200
+ } catch (error) {
1201
+ if (error instanceof FoundryDispatchPrerequisiteError) {
1202
+ return { result: null, reason: 'missing_duration' }
1203
+ }
1204
+ console.error('Scheduled video source capture failed after content save', error)
1205
+ return { result: null, reason: 'dispatch_failed' }
1206
+ }
1207
+ }
1208
+
1117
1209
  function missingDurationResponse(): Response {
1118
1210
  return json(
1119
1211
  {
@@ -1396,6 +1488,33 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1396
1488
  const publishAtSeconds = Math.floor(publishAtMs / 1000)
1397
1489
  const expectedTargets = new Map<string, ScheduleFrontsChannelTargets | undefined>()
1398
1490
  for (const id of ids) {
1491
+ // Same gate as the single-item path: no item in a bulk schedule
1492
+ // may take a slot with a body publish will refuse.
1493
+ const bulkGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
1494
+ requireBody: false,
1495
+ })
1496
+ if (bulkGuard && !bulkGuard.ok) {
1497
+ return json(
1498
+ {
1499
+ error: 'Article body failed pre-publish validation',
1500
+ source: 'cms.schedule',
1501
+ contentId: id,
1502
+ errors: bulkGuard.errors,
1503
+ },
1504
+ 422,
1505
+ )
1506
+ }
1507
+ // And the same capture guarantee: no sourced video in a bulk
1508
+ // schedule may take a slot the producer cannot see.
1509
+ try {
1510
+ await maybeCaptureVideoSource(ctx, id)
1511
+ } catch (error) {
1512
+ if (error instanceof FoundryDispatchPrerequisiteError) {
1513
+ return missingDurationResponse()
1514
+ }
1515
+ console.error('Video source capture failed before bulk schedule', error)
1516
+ return json({ error: 'Foundry dispatch failed', contentId: id }, 502)
1517
+ }
1399
1518
  const prepared = await prepareScheduleTargets(ctx, id)
1400
1519
  if ('error' in prepared) return prepared.error
1401
1520
  expectedTargets.set(id, prepared.targets)
@@ -1407,6 +1526,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1407
1526
  if (targets !== undefined && !changed) {
1408
1527
  return json({ error: 'schedule_targets_changed', contentId: id, scheduled }, 409)
1409
1528
  }
1529
+ // Covers an item that was archived above, now active again.
1530
+ await captureScheduledVideoAfterSave(ctx, id)
1410
1531
  scheduled.push(id)
1411
1532
  }
1412
1533
  return json({ scheduled })
@@ -1712,6 +1833,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1712
1833
  existing.type === 'podcast'
1713
1834
  ? await dispatchPodcastAfterSave(ctx, id)
1714
1835
  : { result: null, reason: null }
1836
+ // A scheduled video is committed to a slot: a save that reset its capture
1837
+ // must not leave it unprepared until someone notices the missed publish.
1838
+ const videoAttempt: PodcastFoundryAttempt =
1839
+ existing.type === 'video' && existing.status === 'scheduled'
1840
+ ? await captureScheduledVideoAfterSave(ctx, id)
1841
+ : { result: null, reason: null }
1715
1842
 
1716
1843
  // If the slug changed, record the old slug as a redirect (spec §8)
1717
1844
  // and drop any stale redirect row for the now-live slug — atomically,
@@ -1726,6 +1853,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1726
1853
  podcastFoundryQueued: Boolean(podcastAttempt.result),
1727
1854
  podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
1728
1855
  podcastFoundryReason: podcastAttempt.reason,
1856
+ videoFoundryQueued: Boolean(videoAttempt.result && !videoAttempt.result.skipped),
1857
+ videoFoundryReason: videoAttempt.reason,
1729
1858
  ...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
1730
1859
  // Present and empty when the body linted clean, so a client can
1731
1860
  // distinguish "no warnings" from "this build does not report them".
@@ -1782,6 +1911,41 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1782
1911
  return json({ error: 'Missing publish time' }, 400)
1783
1912
  }
1784
1913
 
1914
+ // A body the publish guard will refuse must not be allowed to take a
1915
+ // slot. Scheduling was the one write in this lane that never ran
1916
+ // the guard, so a defect an editor could not see at save time
1917
+ // surfaced only when foundryd tried to publish — at 16:30 on a
1918
+ // Monday, as a missed premium article (2026-09-15).
1919
+ const scheduleGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
1920
+ // Claiming a slot before the piece is written stays allowed;
1921
+ // claiming one with an already-broken body does not.
1922
+ requireBody: false,
1923
+ })
1924
+ if (scheduleGuard && !scheduleGuard.ok) {
1925
+ return json(
1926
+ {
1927
+ error: 'Article body failed pre-publish validation',
1928
+ source: 'cms.schedule',
1929
+ errors: scheduleGuard.errors,
1930
+ },
1931
+ 422,
1932
+ )
1933
+ }
1934
+ // A sourced video that holds no capture is invisible to the producer:
1935
+ // it would take the slot and never publish. Capture it here, and refuse
1936
+ // the slot if that cannot be done. A video with no source yet may still
1937
+ // claim a slot early.
1938
+ if (existing.type === 'video') {
1939
+ try {
1940
+ await maybeCaptureVideoSource(ctx, id)
1941
+ } catch (error) {
1942
+ if (error instanceof FoundryDispatchPrerequisiteError) {
1943
+ return missingDurationResponse()
1944
+ }
1945
+ console.error('Video source capture failed before schedule', error)
1946
+ return json({ error: 'Foundry dispatch failed' }, 502)
1947
+ }
1948
+ }
1785
1949
  const prepared = await prepareScheduleTargets(ctx, id)
1786
1950
  if ('error' in prepared) return prepared.error
1787
1951
  const changed = await scheduleContent(
@@ -1794,6 +1958,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1794
1958
  if (prepared.targets !== undefined && !changed) {
1795
1959
  return json({ error: 'schedule_targets_changed', contentId: id }, 409)
1796
1960
  }
1961
+ // Covers an item that was archived above, now active again.
1962
+ if (existing.type === 'video') await captureScheduledVideoAfterSave(ctx, id)
1797
1963
 
1798
1964
  // Fire content.scheduled webhook.
1799
1965
  await fireWebhook(ctx, 'content.scheduled', {
@@ -1809,6 +1975,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1809
1975
  if (action === 'publish') {
1810
1976
  const now = Math.floor(Date.now() / 1000)
1811
1977
  const wasDraft = existing.status === 'draft'
1978
+ const publishedAt =
1979
+ existing.status === 'published' && existing.published_at !== null
1980
+ ? existing.published_at
1981
+ : now
1812
1982
  let podcastDispatch: DispatchResult | null = null
1813
1983
  if (existing.type === 'podcast') {
1814
1984
  try {
@@ -1903,7 +2073,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1903
2073
  throw error
1904
2074
  }
1905
2075
  try {
1906
- await publishContent(ctx.db, id, now)
2076
+ await publishContent(ctx.db, id, publishedAt, now)
1907
2077
  await createRevision(ctx.db, id, ctx.userId || null)
1908
2078
  } catch (error) {
1909
2079
  if (error instanceof TagInputError) return tagInputErrorResponse(error)