@growth-labs/cms 0.8.32 → 0.8.35
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/README.md +20 -0
- package/dist/engine/content-metadata.d.ts +1 -0
- package/dist/engine/content-metadata.d.ts.map +1 -1
- package/dist/engine/content-metadata.js +32 -0
- package/dist/engine/content-metadata.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +3 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +19 -4
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publication.d.ts +3 -1
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +11 -2
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts +4 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +25 -9
- package/dist/engine/publisher.js.map +1 -1
- package/dist/foundry-emit.d.ts +11 -0
- package/dist/foundry-emit.d.ts.map +1 -1
- package/dist/foundry-emit.js +3 -0
- package/dist/foundry-emit.js.map +1 -1
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +76 -1
- package/dist/routes/content-insights.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 -0
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +146 -28
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +7 -9
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +5 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +13 -0
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/content-metadata.ts +35 -0
- package/src/engine/publication-current-guard.ts +28 -4
- package/src/engine/publication.ts +17 -4
- package/src/engine/publisher.ts +33 -7
- package/src/foundry-emit.ts +3 -0
- package/src/routes/content-insights.ts +79 -1
- package/src/routes/content.ts +6 -0
- package/src/routes/foundry-publish.ts +163 -33
- package/src/ui/editor/ContentForm.tsx +8 -13
- package/src/ui/editor/content-payload.ts +17 -0
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
undismissInsight,
|
|
12
12
|
} from '../engine/content-insights.js'
|
|
13
13
|
import { verifyHmac } from '../engine/foundry-dispatch.js'
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getPublishedContentBySlug,
|
|
16
|
+
listPublishedContent,
|
|
17
|
+
PublishedContentReadError,
|
|
18
|
+
} from '../engine/published-content.js'
|
|
15
19
|
import { InsightsIngestPayloadSchema } from '../schema/insights-ingest.js'
|
|
16
20
|
import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
|
|
17
21
|
import type { CmsRouteConfig } from './config.js'
|
|
@@ -56,6 +60,22 @@ const PublishedArticleStateSchema = z
|
|
|
56
60
|
})
|
|
57
61
|
.strict()
|
|
58
62
|
|
|
63
|
+
const PublishedArticleSourceSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
ok: z.literal(true),
|
|
66
|
+
schema: z.literal('cms.published-article-source.v1'),
|
|
67
|
+
site: z.string().min(1),
|
|
68
|
+
observed_at: z.string().datetime(),
|
|
69
|
+
article: PublishedArticleStateSchema.extend({
|
|
70
|
+
hero_image_url: z.string().url().nullable(),
|
|
71
|
+
body_markdown: z.string().min(1).max(1_000_000),
|
|
72
|
+
description: z.string().nullable(),
|
|
73
|
+
byline: z.string().nullable(),
|
|
74
|
+
tags: z.array(z.string()),
|
|
75
|
+
}).strict(),
|
|
76
|
+
})
|
|
77
|
+
.strict()
|
|
78
|
+
|
|
59
79
|
const PublishedInputsResponseSchema = z
|
|
60
80
|
.object({
|
|
61
81
|
ok: z.literal(true),
|
|
@@ -76,6 +96,64 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
|
|
|
76
96
|
return json({ error: 'invalid_published_mode' }, 400)
|
|
77
97
|
if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
|
|
78
98
|
if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
|
|
99
|
+
const articleSlug = url.searchParams.get('article')
|
|
100
|
+
if (articleSlug !== null) {
|
|
101
|
+
const slug = z
|
|
102
|
+
.string()
|
|
103
|
+
.min(1)
|
|
104
|
+
.max(512)
|
|
105
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/)
|
|
106
|
+
.safeParse(articleSlug)
|
|
107
|
+
if (!slug.success || url.searchParams.has('cursor') || url.searchParams.has('limit'))
|
|
108
|
+
return json({ error: 'invalid_article_source_request' }, 400)
|
|
109
|
+
try {
|
|
110
|
+
const content = await getPublishedContentBySlug(ctx.db, slug.data)
|
|
111
|
+
if (!content || content.type !== 'article')
|
|
112
|
+
return json({ error: 'published_article_missing' }, 404)
|
|
113
|
+
const item = content.payload.item
|
|
114
|
+
let heroImageUrl: string | null = null
|
|
115
|
+
if (item.hero_image_id) {
|
|
116
|
+
const asset = await ctx.db
|
|
117
|
+
.prepare(
|
|
118
|
+
"SELECT public_url FROM media_assets WHERE id = ? AND asset_type = 'image' AND status = 'ready'",
|
|
119
|
+
)
|
|
120
|
+
.bind(item.hero_image_id)
|
|
121
|
+
.first<{ public_url: string | null }>()
|
|
122
|
+
const publicUrl = z.string().url().safeParse(asset?.public_url)
|
|
123
|
+
if (publicUrl.success) heroImageUrl = publicUrl.data
|
|
124
|
+
}
|
|
125
|
+
const result = PublishedArticleSourceSchema.safeParse({
|
|
126
|
+
ok: true,
|
|
127
|
+
schema: 'cms.published-article-source.v1',
|
|
128
|
+
site: siteId,
|
|
129
|
+
observed_at: new Date().toISOString(),
|
|
130
|
+
article: {
|
|
131
|
+
content_id: content.contentId,
|
|
132
|
+
revision_id: content.revisionId,
|
|
133
|
+
slug: content.slug,
|
|
134
|
+
title: item.title,
|
|
135
|
+
canonical_url: item.canonical_url ?? null,
|
|
136
|
+
published_at: content.publishedAt,
|
|
137
|
+
source_id: item.source_id ?? null,
|
|
138
|
+
youtube_video_id: content.metadata.videoId ?? null,
|
|
139
|
+
hero_image_id: item.hero_image_id ?? null,
|
|
140
|
+
hero_image_url: heroImageUrl,
|
|
141
|
+
hero_image_caption: item.hero_image_caption ?? null,
|
|
142
|
+
body_markdown: content.payload.content?.body_markdown,
|
|
143
|
+
description: item.description ?? null,
|
|
144
|
+
byline: item.byline ?? null,
|
|
145
|
+
tags: content.payload.tags,
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
if (!result.success) return json({ error: 'published_article_source_invalid' }, 409)
|
|
149
|
+
if (new TextEncoder().encode(JSON.stringify(result.data)).byteLength > 4 << 20)
|
|
150
|
+
return json({ error: 'published_article_source_too_large' }, 409)
|
|
151
|
+
return json(result.data)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
}
|
|
79
157
|
const limit = z.coerce
|
|
80
158
|
.number()
|
|
81
159
|
.int()
|
package/src/routes/content.ts
CHANGED
|
@@ -235,6 +235,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
235
235
|
videoSourceProductionEnabled?: boolean
|
|
236
236
|
/** Internal machine-route constraint: atomically update only in this status. */
|
|
237
237
|
updateStatusConstraint?: ContentStatus
|
|
238
|
+
/** Internal machine-route pin; joins the existing status-constrained atomic batch. */
|
|
239
|
+
updateCanonicalVersionConstraint?: number
|
|
238
240
|
/**
|
|
239
241
|
* Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
|
|
240
242
|
* commit step (publishContent + createRevision) with the caller's — publishOne
|
|
@@ -1807,6 +1809,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1807
1809
|
try {
|
|
1808
1810
|
updated = await updateContentItem(ctx.db, id, parsed.data, {
|
|
1809
1811
|
requiredStatus: config.updateStatusConstraint,
|
|
1812
|
+
expectedCanonicalVersion: config.updateCanonicalVersionConstraint,
|
|
1810
1813
|
...(expectedMediaSource ? { expectedMediaSource } : {}),
|
|
1811
1814
|
})
|
|
1812
1815
|
} catch (error) {
|
|
@@ -1821,6 +1824,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1821
1824
|
if (updated.statusConflict) {
|
|
1822
1825
|
return json({ error: 'update_status_conflict', state: updated.statusConflict }, 409)
|
|
1823
1826
|
}
|
|
1827
|
+
if (updated.canonicalVersionConflict) {
|
|
1828
|
+
return json({ error: 'update_canonical_conflict' }, 409)
|
|
1829
|
+
}
|
|
1824
1830
|
if (updated.mediaSourceConflict) {
|
|
1825
1831
|
return json({ error: 'media_source_conflict' }, 409)
|
|
1826
1832
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/foundry-signing'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
+
import { ContentMetadataSchema, mergePinnedArticleMetadata } from '../engine/content-metadata.js'
|
|
3
4
|
import { type PublicationReconciler, publishOne } from '../engine/publication.js'
|
|
5
|
+
import { PublicationCurrentSnapshotError } from '../engine/publication-current-guard.js'
|
|
4
6
|
import { PublishContentValidationError } from '../engine/publisher.js'
|
|
5
|
-
import { createRevisionWithDelta } from '../engine/revisions.js'
|
|
7
|
+
import { createRevisionWithDelta, getRevision } from '../engine/revisions.js'
|
|
6
8
|
import { buildContentPath, slugify } from '../engine/slug.js'
|
|
7
9
|
import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.js'
|
|
8
10
|
import { type ContentRouteConfig, createContentRoutes } from './content.js'
|
|
@@ -20,6 +22,10 @@ const PutSchema = z
|
|
|
20
22
|
})
|
|
21
23
|
.strict()
|
|
22
24
|
|
|
25
|
+
const PinnedPublishSchema = z
|
|
26
|
+
.object({ expected_request_sha256: z.string().regex(/^[a-f0-9]{64}$/) })
|
|
27
|
+
.strict()
|
|
28
|
+
|
|
23
29
|
interface FoundryArticleRow {
|
|
24
30
|
external_id: string
|
|
25
31
|
content_id: string
|
|
@@ -258,6 +264,9 @@ async function mapUpdateConflict(response: Response, id: string): Promise<Respon
|
|
|
258
264
|
if (response.status !== 409) return response
|
|
259
265
|
try {
|
|
260
266
|
const payload = (await response.clone().json()) as { error?: unknown; state?: unknown }
|
|
267
|
+
if (payload.error === 'update_canonical_conflict') {
|
|
268
|
+
return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
269
|
+
}
|
|
261
270
|
if (payload.error === 'update_status_conflict' && typeof payload.state === 'string') {
|
|
262
271
|
return approvalConflict(id, payload.state)
|
|
263
272
|
}
|
|
@@ -465,6 +474,7 @@ async function bindAdoption(
|
|
|
465
474
|
config: ContentRouteConfig,
|
|
466
475
|
id: string,
|
|
467
476
|
candidate: ContentCandidate,
|
|
477
|
+
expectedVersion?: number,
|
|
468
478
|
): Promise<boolean> {
|
|
469
479
|
const sourceId = `${SOURCE_PREFIX}${id}`
|
|
470
480
|
await ctx.db.batch([
|
|
@@ -472,9 +482,17 @@ async function bindAdoption(
|
|
|
472
482
|
.prepare(
|
|
473
483
|
`UPDATE content_items SET source_id = ?, updated_at = unixepoch()
|
|
474
484
|
WHERE id = ? AND deleted_at IS NULL AND type = 'article' AND source_id = ?
|
|
475
|
-
AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)
|
|
485
|
+
AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)
|
|
486
|
+
AND (? IS NULL OR canonical_version = ?)`,
|
|
476
487
|
)
|
|
477
|
-
.bind(
|
|
488
|
+
.bind(
|
|
489
|
+
sourceId,
|
|
490
|
+
candidate.id,
|
|
491
|
+
candidate.source_id,
|
|
492
|
+
candidate.id,
|
|
493
|
+
expectedVersion ?? null,
|
|
494
|
+
expectedVersion ?? null,
|
|
495
|
+
),
|
|
478
496
|
ctx.db
|
|
479
497
|
.prepare(
|
|
480
498
|
`INSERT INTO cms_activity_log (id, workspace_id, user_id, action, target_id, target_title, type)
|
|
@@ -491,6 +509,8 @@ async function bindAdoption(
|
|
|
491
509
|
}
|
|
492
510
|
|
|
493
511
|
function publishFailure(id: string, error: unknown): Response {
|
|
512
|
+
if (error instanceof PublicationCurrentSnapshotError)
|
|
513
|
+
return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
494
514
|
if (error instanceof PublishContentValidationError) {
|
|
495
515
|
return json({ error: 'publish_validation_failed', external_id: id, errors: error.errors }, 422)
|
|
496
516
|
}
|
|
@@ -511,6 +531,27 @@ async function canonicalVersion(ctx: RouteContext, contentId: string): Promise<n
|
|
|
511
531
|
return row?.canonical_version ?? null
|
|
512
532
|
}
|
|
513
533
|
|
|
534
|
+
/** A signed improvement names the actual immutable CMS revision it read. */
|
|
535
|
+
async function sourceRevisionVersion(
|
|
536
|
+
ctx: RouteContext,
|
|
537
|
+
contentId: string,
|
|
538
|
+
revisionId: string,
|
|
539
|
+
): Promise<number | null> {
|
|
540
|
+
const revision = await getRevision(ctx.db, contentId, revisionId)
|
|
541
|
+
if (!revision) return null
|
|
542
|
+
const item = revision.payload.item as Record<string, unknown> | undefined
|
|
543
|
+
const version = item?.canonical_version
|
|
544
|
+
if (
|
|
545
|
+
item?.id !== contentId ||
|
|
546
|
+
item.type !== 'article' ||
|
|
547
|
+
typeof version !== 'number' ||
|
|
548
|
+
!Number.isSafeInteger(version) ||
|
|
549
|
+
version < 1
|
|
550
|
+
)
|
|
551
|
+
return null
|
|
552
|
+
return (await canonicalVersion(ctx, contentId)) === version ? version : null
|
|
553
|
+
}
|
|
554
|
+
|
|
514
555
|
/**
|
|
515
556
|
* Record what this v2 PUT wrote as a History revision (the autosave/restore
|
|
516
557
|
* mechanism; it never moves the published pointer), created_by `foundry` and
|
|
@@ -545,29 +586,23 @@ async function recordFoundryWrite(
|
|
|
545
586
|
}
|
|
546
587
|
}
|
|
547
588
|
|
|
548
|
-
/**
|
|
549
|
-
async function
|
|
589
|
+
/** The immutable last PUT snapshot, only while its canonical version is current. */
|
|
590
|
+
async function lastFoundryWriteSnapshot(
|
|
550
591
|
ctx: RouteContext,
|
|
551
592
|
row: FoundryArticleRow,
|
|
552
|
-
): Promise<
|
|
553
|
-
const
|
|
554
|
-
.prepare(
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
.first<{ current_version: number | null; put_version: number | null }>()
|
|
566
|
-
return (
|
|
567
|
-
versions !== null &&
|
|
568
|
-
versions.put_version !== null &&
|
|
569
|
-
versions.current_version === versions.put_version
|
|
570
|
-
)
|
|
593
|
+
): Promise<Record<string, unknown> | null> {
|
|
594
|
+
const match = await ctx.db
|
|
595
|
+
.prepare(`SELECT r.id AS revision_id
|
|
596
|
+
FROM content_items c JOIN content_revisions r ON r.content_id = c.id
|
|
597
|
+
WHERE c.id = ? AND c.deleted_at IS NULL AND r.created_by = 'foundry' AND r.tag = ?
|
|
598
|
+
AND c.canonical_version IS NOT NULL
|
|
599
|
+
AND c.canonical_version = json_extract(r.payload_json, '$.item.canonical_version')
|
|
600
|
+
ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1`)
|
|
601
|
+
.bind(row.content_id, `${FOUNDRY_PUT_TAG}${row.request_sha256}`)
|
|
602
|
+
.first<{ revision_id: string }>()
|
|
603
|
+
if (!match) return null
|
|
604
|
+
const revision = await getRevision(ctx.db, row.content_id, match.revision_id)
|
|
605
|
+
return revision ? { ...revision.payload } : null
|
|
571
606
|
}
|
|
572
607
|
|
|
573
608
|
/** The v2 receipt: the four v1 fields plus the stored request hash. */
|
|
@@ -594,6 +629,12 @@ async function putV2(
|
|
|
594
629
|
const body = parsed.data
|
|
595
630
|
|
|
596
631
|
const prior = await findArticle(ctx, id)
|
|
632
|
+
if (body.createOnly && prior && (prior.request_sha256 !== bodyHash || prior.status !== 'draft')) {
|
|
633
|
+
return json(
|
|
634
|
+
{ error: 'article_already_exists', external_id: id, content_id: prior.content_id },
|
|
635
|
+
409,
|
|
636
|
+
)
|
|
637
|
+
}
|
|
597
638
|
if (prior?.request_sha256 === bodyHash) {
|
|
598
639
|
console.info('[cms] foundry article request deduplicated', {
|
|
599
640
|
externalId: id,
|
|
@@ -609,8 +650,41 @@ async function putV2(
|
|
|
609
650
|
// Scheduled, review and archived items stay human-owned.
|
|
610
651
|
if (bound && !V2_WRITABLE.has(bound.status)) return approvalConflict(id, bound.status)
|
|
611
652
|
|
|
653
|
+
if (body.createOnly) {
|
|
654
|
+
if (body.expectedRevisionId) return json({ error: 'create_only_has_source_revision' }, 400)
|
|
655
|
+
if (body.adoptSourceId || bound)
|
|
656
|
+
return json({ error: 'article_already_exists', external_id: id }, 409)
|
|
657
|
+
const slugHolder = await findCandidate(ctx, 'slug', body.slug)
|
|
658
|
+
if (slugHolder) return slugConflict(id, body.slug, slugHolder.id)
|
|
659
|
+
const videoId = body.metadata?.videoId
|
|
660
|
+
if (typeof videoId !== 'string' || !/^[A-Za-z0-9_-]{11}$/.test(videoId))
|
|
661
|
+
return json({ error: 'create_only_requires_source_video' }, 400)
|
|
662
|
+
// Check the actual CMS working and immutable published projections, not
|
|
663
|
+
// just Foundry's own relationship ledger. A creation cannot adopt or edit.
|
|
664
|
+
const associated = await ctx.db
|
|
665
|
+
.prepare(`SELECT c.id FROM content_items c
|
|
666
|
+
LEFT JOIN content_revisions r ON r.id=c.published_revision_id AND r.content_id=c.id
|
|
667
|
+
WHERE c.type='article' AND c.deleted_at IS NULL AND (
|
|
668
|
+
CASE WHEN json_valid(c.metadata_json) THEN json_extract(c.metadata_json,'$.videoId') END = ?
|
|
669
|
+
OR CASE WHEN json_valid(json_extract(r.payload_json,'$.item.metadata_json')) THEN json_extract(json_extract(r.payload_json,'$.item.metadata_json'),'$.videoId') END = ?
|
|
670
|
+
) LIMIT 1`)
|
|
671
|
+
.bind(videoId, videoId)
|
|
672
|
+
.first<{ id: string }>()
|
|
673
|
+
if (associated)
|
|
674
|
+
return json(
|
|
675
|
+
{ error: 'article_video_already_exists', external_id: id, content_id: associated.id },
|
|
676
|
+
409,
|
|
677
|
+
)
|
|
678
|
+
}
|
|
612
679
|
let adoption: Adoption | null = null
|
|
680
|
+
let expectedSourceVersion: number | undefined
|
|
613
681
|
if (bound) {
|
|
682
|
+
if (body.expectedRevisionId) {
|
|
683
|
+
const version = await sourceRevisionVersion(ctx, bound.id, body.expectedRevisionId)
|
|
684
|
+
if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
685
|
+
expectedSourceVersion = version
|
|
686
|
+
}
|
|
687
|
+
|
|
614
688
|
const holder = await findCandidate(ctx, 'slug', body.slug)
|
|
615
689
|
if (holder && holder.id !== bound.id) return slugConflict(id, body.slug, holder.id)
|
|
616
690
|
} else {
|
|
@@ -618,7 +692,12 @@ async function putV2(
|
|
|
618
692
|
if ('error' in resolved) return resolved.error
|
|
619
693
|
if (resolved.candidate && resolved.matchedBy) {
|
|
620
694
|
const candidate = resolved.candidate
|
|
621
|
-
if (
|
|
695
|
+
if (body.expectedRevisionId) {
|
|
696
|
+
const version = await sourceRevisionVersion(ctx, candidate.id, body.expectedRevisionId)
|
|
697
|
+
if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
698
|
+
expectedSourceVersion = version
|
|
699
|
+
}
|
|
700
|
+
if (!(await bindAdoption(ctx, config, id, candidate, expectedSourceVersion))) {
|
|
622
701
|
return json(
|
|
623
702
|
{
|
|
624
703
|
error: 'adoption_conflict',
|
|
@@ -655,11 +734,34 @@ async function putV2(
|
|
|
655
734
|
}
|
|
656
735
|
}
|
|
657
736
|
|
|
658
|
-
//
|
|
659
|
-
|
|
737
|
+
// Preserve a newer human working copy before PUT, not merely at publication.
|
|
738
|
+
// The native update pins this same canonical version in its atomic batch.
|
|
739
|
+
if (body.expectedRevisionId && !bound)
|
|
740
|
+
return json({ error: 'source_revision_missing', external_id: id }, 409)
|
|
741
|
+
const priorVersion =
|
|
742
|
+
expectedSourceVersion ?? (bound ? await canonicalVersion(ctx, bound.id) : null)
|
|
660
743
|
const heroImageId = body.hero_image_url
|
|
661
744
|
? await heroAssetId(body.hero_image_url)
|
|
662
745
|
: (body.hero_image_key ?? undefined)
|
|
746
|
+
// A pinned improvement augments its exact source metadata. Existing dates,
|
|
747
|
+
// image provenance and other recorded fields survive preparation updates.
|
|
748
|
+
let metadata = body.metadata
|
|
749
|
+
if (body.expectedRevisionId && body.metadata !== undefined && bound) {
|
|
750
|
+
const source = await getRevision(ctx.db, bound.id, body.expectedRevisionId)
|
|
751
|
+
let original: unknown
|
|
752
|
+
try {
|
|
753
|
+
original = JSON.parse(String(source?.payload.item?.metadata_json ?? '{}'))
|
|
754
|
+
} catch {
|
|
755
|
+
return json({ error: 'source_metadata_invalid', external_id: id }, 409)
|
|
756
|
+
}
|
|
757
|
+
const recorded = ContentMetadataSchema.safeParse(original)
|
|
758
|
+
if (!recorded.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
|
|
759
|
+
const merged = ContentMetadataSchema.safeParse(
|
|
760
|
+
mergePinnedArticleMetadata(recorded.data, body.metadata),
|
|
761
|
+
)
|
|
762
|
+
if (!merged.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
|
|
763
|
+
metadata = merged.data
|
|
764
|
+
}
|
|
663
765
|
const nativeBody = {
|
|
664
766
|
title: body.title,
|
|
665
767
|
slug: body.slug,
|
|
@@ -671,7 +773,7 @@ async function putV2(
|
|
|
671
773
|
...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
|
|
672
774
|
...(body.tags !== undefined ? { tags: body.tags } : {}),
|
|
673
775
|
...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
|
|
674
|
-
...(
|
|
776
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
675
777
|
content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
|
|
676
778
|
}
|
|
677
779
|
|
|
@@ -680,7 +782,12 @@ async function putV2(
|
|
|
680
782
|
// Each write is a CAS on the status it was read in: a draft stays a
|
|
681
783
|
// draft write, and a published item is edited in place only while it is
|
|
682
784
|
// still published (its live revision changes only on POST /publish).
|
|
683
|
-
|
|
785
|
+
if (priorVersion === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
786
|
+
const writer = createContentRoutes({
|
|
787
|
+
...machineConfig(config),
|
|
788
|
+
updateStatusConstraint: bound.status,
|
|
789
|
+
updateCanonicalVersionConstraint: priorVersion,
|
|
790
|
+
})
|
|
684
791
|
nativeResponse = await mapUpdateConflict(
|
|
685
792
|
await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
|
|
686
793
|
id,
|
|
@@ -697,6 +804,8 @@ async function putV2(
|
|
|
697
804
|
)
|
|
698
805
|
} catch (error) {
|
|
699
806
|
const raced = await findContentBySource(ctx, id)
|
|
807
|
+
if (body.createOnly && raced)
|
|
808
|
+
return json({ error: 'article_already_exists', external_id: id, content_id: raced.id }, 409)
|
|
700
809
|
if (raced?.type !== 'article') throw error
|
|
701
810
|
nativeResponse = await mapUpdateConflict(
|
|
702
811
|
await handlers.draft.update(nativeContext(ctx, 'PATCH', nativeBody, { id: raced.id })),
|
|
@@ -777,7 +886,8 @@ async function publishV2(
|
|
|
777
886
|
// Publish, schedule and resync alike act on the working copy only while it is
|
|
778
887
|
// still exactly what Foundry's last v2 PUT wrote: a human edit since then (to
|
|
779
888
|
// a draft or a published item) is never machine-published or machine-scheduled.
|
|
780
|
-
|
|
889
|
+
const expectedSnapshot = await lastFoundryWriteSnapshot(ctx, row)
|
|
890
|
+
if (!expectedSnapshot) {
|
|
781
891
|
return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
782
892
|
}
|
|
783
893
|
|
|
@@ -833,6 +943,8 @@ async function publishV2(
|
|
|
833
943
|
now: input.now,
|
|
834
944
|
publishedAt,
|
|
835
945
|
createdBy: 'foundry',
|
|
946
|
+
expectedFoundryArticleSnapshot: expectedSnapshot,
|
|
947
|
+
expectedReceiptIdentity: row.request_sha256,
|
|
836
948
|
surfaces,
|
|
837
949
|
...(config.foundryPublish?.requiredSurfaceNames
|
|
838
950
|
? { requiredSurfaceNames: config.foundryPublish.requiredSurfaceNames }
|
|
@@ -1060,14 +1172,32 @@ export function createFoundryPublishRoutes(
|
|
|
1060
1172
|
const rawBody = await ctx.request.text()
|
|
1061
1173
|
const auth = await verify(ctx, rawBody)
|
|
1062
1174
|
if ('error' in auth) return auth.error
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
|
|
1175
|
+
// Legacy callers publish the current write. Strategy execution pins the
|
|
1176
|
+
// exact PUT bytes in the signed body; an unrelated replacement is refused.
|
|
1177
|
+
let expectedHash: string | undefined
|
|
1178
|
+
if (rawBody.length > 0) {
|
|
1179
|
+
let value: unknown
|
|
1180
|
+
try {
|
|
1181
|
+
value = JSON.parse(rawBody)
|
|
1182
|
+
} catch {
|
|
1183
|
+
return json({ error: 'invalid_request' }, 400)
|
|
1184
|
+
}
|
|
1185
|
+
const parsed = PinnedPublishSchema.safeParse(value)
|
|
1186
|
+
if (!parsed.success) return json({ error: 'invalid_request' }, 400)
|
|
1187
|
+
expectedHash = parsed.data.expected_request_sha256
|
|
1188
|
+
} else if (!/^0?$/.test(ctx.request.headers.get('Content-Length') ?? '')) {
|
|
1189
|
+
return json(
|
|
1190
|
+
{ error: 'invalid_request', detail: 'empty publish request declares a body' },
|
|
1191
|
+
400,
|
|
1192
|
+
)
|
|
1066
1193
|
}
|
|
1067
1194
|
const row = await findArticle(ctx, id)
|
|
1068
1195
|
if (!row) return json({ error: 'not_found' }, 404)
|
|
1069
1196
|
// Without the publication's opt-in the machine route stays draft-only.
|
|
1070
1197
|
if (!autoPublishEnabled(config)) return approvalConflict(id, row.status)
|
|
1198
|
+
if (expectedHash !== undefined && expectedHash !== row.request_sha256) {
|
|
1199
|
+
return json({ error: 'article_revision_changed', external_id: id }, 409)
|
|
1200
|
+
}
|
|
1071
1201
|
return publishV2(ctx, config, content, id, row)
|
|
1072
1202
|
},
|
|
1073
1203
|
}
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
applyMediaSourceReadiness,
|
|
34
34
|
buildContentCreatePayload,
|
|
35
35
|
buildContentUpdatePayload,
|
|
36
|
-
|
|
36
|
+
contentDraftSaveError,
|
|
37
37
|
foundryDispatchSourceForDraft,
|
|
38
38
|
initialContentMetadata,
|
|
39
39
|
type MediaSourceReadinessPayload,
|
|
@@ -406,19 +406,14 @@ export function ContentForm({
|
|
|
406
406
|
const draft = currentForm
|
|
407
407
|
|
|
408
408
|
if (id === null) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
) {
|
|
414
|
-
const mediaSource = foundryDispatchSourceForDraft(contentType, draft)
|
|
415
|
-
const missingMediaDuration =
|
|
416
|
-
mediaSource && normalizeMediaDurationSeconds(draft.durationSeconds) === null
|
|
409
|
+
const saveError = contentDraftSaveError(contentType, draft, {
|
|
410
|
+
requirePrimaryTopic: primaryTopicEnabled,
|
|
411
|
+
primaryTopicLabel: primaryTopic?.label,
|
|
412
|
+
})
|
|
413
|
+
if (saveError) {
|
|
417
414
|
setForm((prev) => ({
|
|
418
415
|
...prev,
|
|
419
|
-
saveError
|
|
420
|
-
? 'Enter the authoritative media duration in whole seconds before saving.'
|
|
421
|
-
: 'Add the required title and content before saving.',
|
|
416
|
+
saveError,
|
|
422
417
|
}))
|
|
423
418
|
return
|
|
424
419
|
}
|
|
@@ -543,7 +538,7 @@ export function ContentForm({
|
|
|
543
538
|
}
|
|
544
539
|
}
|
|
545
540
|
},
|
|
546
|
-
[contentType, onDocCreated, primaryTopicEnabled, queueFoundryProcessing],
|
|
541
|
+
[contentType, onDocCreated, primaryTopicEnabled, primaryTopic?.label, queueFoundryProcessing],
|
|
547
542
|
)
|
|
548
543
|
|
|
549
544
|
useEffect(() => {
|
|
@@ -156,6 +156,23 @@ export function foundryDispatchSourceForDraft(
|
|
|
156
156
|
return null
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Explain the same prerequisites that gate the first save, using the host's field label. */
|
|
160
|
+
export function contentDraftSaveError(
|
|
161
|
+
contentType: ContentType,
|
|
162
|
+
draft: ContentDraftFields,
|
|
163
|
+
requirements: { requirePrimaryTopic?: boolean; primaryTopicLabel?: string } = {},
|
|
164
|
+
): string | null {
|
|
165
|
+
if (canCreateContentDraft(contentType, draft, requirements)) return null
|
|
166
|
+
if (requirements.requirePrimaryTopic && !draft.primaryTopic?.trim()) {
|
|
167
|
+
return `Select ${requirements.primaryTopicLabel || 'a primary topic'} before saving.`
|
|
168
|
+
}
|
|
169
|
+
const mediaSource = foundryDispatchSourceForDraft(contentType, draft)
|
|
170
|
+
if (mediaSource && normalizeMediaDurationSeconds(draft.durationSeconds) === null) {
|
|
171
|
+
return 'Enter the authoritative media duration in whole seconds before saving.'
|
|
172
|
+
}
|
|
173
|
+
return 'Add the required title and content before saving.'
|
|
174
|
+
}
|
|
175
|
+
|
|
159
176
|
export function inferVideoSourceKind(sourceUrl: string): 'dropbox' | 'http_url' {
|
|
160
177
|
try {
|
|
161
178
|
const { hostname } = new URL(sourceUrl)
|