@growth-labs/cms 0.8.33 → 0.8.38
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/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/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 +61 -1
- package/dist/foundry-emit.d.ts.map +1 -1
- package/dist/foundry-emit.js +16 -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 +78 -19
- 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 +196 -10
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +1 -1
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/routes/media-lib.d.ts +2 -0
- package/dist/routes/media-lib.d.ts.map +1 -1
- package/dist/routes/media-lib.js +17 -0
- package/dist/routes/media-lib.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/publisher.ts +33 -7
- package/src/foundry-emit.ts +22 -0
- package/src/routes/content-insights.ts +85 -18
- package/src/routes/content.ts +6 -0
- package/src/routes/foundry-publish.ts +215 -9
- package/src/routes/fronts-publish.ts +4 -1
- package/src/routes/media-lib.ts +18 -0
- package/src/ui/editor/ContentForm.tsx +8 -13
- package/src/ui/editor/content-payload.ts +17 -0
package/src/foundry-emit.ts
CHANGED
|
@@ -35,6 +35,15 @@ const HERO_IMAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9._\-/]*$/
|
|
|
35
35
|
export const FoundryArticleV2Schema = z
|
|
36
36
|
.object({
|
|
37
37
|
schemaVersion: z.literal(FOUNDRY_ARTICLE_SCHEMA_VERSION),
|
|
38
|
+
createOnly: z.literal(true).optional(),
|
|
39
|
+
/** Actual CMS source revision pinned by a strategy-led improvement. */
|
|
40
|
+
expectedRevisionId: z.string().uuid().optional(),
|
|
41
|
+
/** Continue this writer's exact private snapshot, without adopting newer edits. */
|
|
42
|
+
expectedRequestSHA256: z
|
|
43
|
+
.string()
|
|
44
|
+
.regex(/^[a-f0-9]{64}$/)
|
|
45
|
+
.optional(),
|
|
46
|
+
expectedCanonicalVersion: z.number().int().positive().optional(),
|
|
38
47
|
slug: z.string().refine(isCanonicalSlug, {
|
|
39
48
|
message: 'slug must already be canonical: lowercase a-z, 0-9 and single hyphens, 3-512 chars',
|
|
40
49
|
}),
|
|
@@ -70,6 +79,19 @@ export const FoundryArticleV2Schema = z
|
|
|
70
79
|
.optional(),
|
|
71
80
|
})
|
|
72
81
|
.strict()
|
|
82
|
+
.refine(
|
|
83
|
+
(body) =>
|
|
84
|
+
(body.expectedRequestSHA256 === undefined) ===
|
|
85
|
+
(body.expectedCanonicalVersion === undefined) &&
|
|
86
|
+
!(
|
|
87
|
+
body.expectedRequestSHA256 &&
|
|
88
|
+
(body.createOnly || body.expectedRevisionId || body.adoptSourceId)
|
|
89
|
+
),
|
|
90
|
+
{
|
|
91
|
+
message: 'own working update requires both pins and cannot create or adopt',
|
|
92
|
+
path: ['expectedRequestSHA256'],
|
|
93
|
+
},
|
|
94
|
+
)
|
|
73
95
|
.refine((body) => !(body.hero_image_key && body.hero_image_url), {
|
|
74
96
|
message: 'send hero_image_key or hero_image_url, not both',
|
|
75
97
|
path: ['hero_image_key'],
|
|
@@ -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'
|
|
@@ -21,6 +25,7 @@ import {
|
|
|
21
25
|
resolveFoundryHmacSecret as resolveFoundryHmacSecretForInputs,
|
|
22
26
|
verifyFoundrySignedRequest,
|
|
23
27
|
} from './fronts-publish.js'
|
|
28
|
+
import { readReadyImagePublicUrl } from './media-lib.js'
|
|
24
29
|
|
|
25
30
|
const DismissSchema = z
|
|
26
31
|
.object({
|
|
@@ -56,6 +61,22 @@ const PublishedArticleStateSchema = z
|
|
|
56
61
|
})
|
|
57
62
|
.strict()
|
|
58
63
|
|
|
64
|
+
const PublishedArticleSourceSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
ok: z.literal(true),
|
|
67
|
+
schema: z.literal('cms.published-article-source.v1'),
|
|
68
|
+
site: z.string().min(1),
|
|
69
|
+
observed_at: z.string().datetime(),
|
|
70
|
+
article: PublishedArticleStateSchema.extend({
|
|
71
|
+
hero_image_url: z.string().url().nullable(),
|
|
72
|
+
body_markdown: z.string().min(1).max(1_000_000),
|
|
73
|
+
description: z.string().nullable(),
|
|
74
|
+
byline: z.string().nullable(),
|
|
75
|
+
tags: z.array(z.string()),
|
|
76
|
+
}).strict(),
|
|
77
|
+
})
|
|
78
|
+
.strict()
|
|
79
|
+
|
|
59
80
|
const PublishedInputsResponseSchema = z
|
|
60
81
|
.object({
|
|
61
82
|
ok: z.literal(true),
|
|
@@ -76,6 +97,54 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
|
|
|
76
97
|
return json({ error: 'invalid_published_mode' }, 400)
|
|
77
98
|
if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
|
|
78
99
|
if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
|
|
100
|
+
const articleSlug = url.searchParams.get('article')
|
|
101
|
+
if (articleSlug !== null) {
|
|
102
|
+
const slug = z
|
|
103
|
+
.string()
|
|
104
|
+
.min(1)
|
|
105
|
+
.max(512)
|
|
106
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/)
|
|
107
|
+
.safeParse(articleSlug)
|
|
108
|
+
if (!slug.success || url.searchParams.has('cursor') || url.searchParams.has('limit'))
|
|
109
|
+
return json({ error: 'invalid_article_source_request' }, 400)
|
|
110
|
+
try {
|
|
111
|
+
const content = await getPublishedContentBySlug(ctx.db, slug.data)
|
|
112
|
+
if (!content || content.type !== 'article')
|
|
113
|
+
return json({ error: 'published_article_missing' }, 404)
|
|
114
|
+
const item = content.payload.item
|
|
115
|
+
const heroImageUrl = await readReadyImagePublicUrl(ctx.db, item.hero_image_id)
|
|
116
|
+
const result = PublishedArticleSourceSchema.safeParse({
|
|
117
|
+
ok: true,
|
|
118
|
+
schema: 'cms.published-article-source.v1',
|
|
119
|
+
site: siteId,
|
|
120
|
+
observed_at: new Date().toISOString(),
|
|
121
|
+
article: {
|
|
122
|
+
content_id: content.contentId,
|
|
123
|
+
revision_id: content.revisionId,
|
|
124
|
+
slug: content.slug,
|
|
125
|
+
title: item.title,
|
|
126
|
+
canonical_url: item.canonical_url ?? null,
|
|
127
|
+
published_at: content.publishedAt,
|
|
128
|
+
source_id: item.source_id ?? null,
|
|
129
|
+
youtube_video_id: content.metadata.videoId ?? null,
|
|
130
|
+
hero_image_id: item.hero_image_id ?? null,
|
|
131
|
+
hero_image_url: heroImageUrl,
|
|
132
|
+
hero_image_caption: item.hero_image_caption ?? null,
|
|
133
|
+
body_markdown: content.payload.content?.body_markdown,
|
|
134
|
+
description: item.description ?? null,
|
|
135
|
+
byline: item.byline ?? null,
|
|
136
|
+
tags: content.payload.tags,
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
if (!result.success) return json({ error: 'published_article_source_invalid' }, 409)
|
|
140
|
+
if (new TextEncoder().encode(JSON.stringify(result.data)).byteLength > 4 << 20)
|
|
141
|
+
return json({ error: 'published_article_source_too_large' }, 409)
|
|
142
|
+
return json(result.data)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
147
|
+
}
|
|
79
148
|
const limit = z.coerce
|
|
80
149
|
.number()
|
|
81
150
|
.int()
|
|
@@ -137,7 +206,7 @@ function resolveRunDate(ctx: RouteContext): string {
|
|
|
137
206
|
}
|
|
138
207
|
|
|
139
208
|
/**
|
|
140
|
-
* Resolve the
|
|
209
|
+
* Resolve the explicit legacy ingest binding, or the declared shared CMS machine binding for channel publications.
|
|
141
210
|
* Supports both:
|
|
142
211
|
* - plain string (test injection: env: { INSIGHTS_INGEST_HMAC_SECRET: 'test-...' })
|
|
143
212
|
* - Secrets Store binding object { get(): Promise<string> } (production Workers binding)
|
|
@@ -146,21 +215,13 @@ function resolveRunDate(ctx: RouteContext): string {
|
|
|
146
215
|
async function resolveIngestSecret(
|
|
147
216
|
env: Record<string, unknown> | undefined,
|
|
148
217
|
): Promise<string | null> {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
try {
|
|
157
|
-
const val = await (raw as { get(): Promise<string> }).get()
|
|
158
|
-
if (typeof val === 'string' && val.length > 0) return val
|
|
159
|
-
} catch {
|
|
160
|
-
return null
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return null
|
|
218
|
+
// Preserve explicit legacy ingest credentials, including their failures.
|
|
219
|
+
// Channel publications already declare the shared CMS machine binding.
|
|
220
|
+
const binding =
|
|
221
|
+
env?.INSIGHTS_INGEST_HMAC_SECRET === undefined
|
|
222
|
+
? 'CMS_FOUNDRY_HMAC_SECRET'
|
|
223
|
+
: 'INSIGHTS_INGEST_HMAC_SECRET'
|
|
224
|
+
return resolveFoundryHmacSecretForInputs(env, binding)
|
|
164
225
|
}
|
|
165
226
|
|
|
166
227
|
export interface ContentInsightsRouteConfig extends CmsRouteConfig {
|
|
@@ -277,7 +338,13 @@ export function createContentInsightsRoutes(
|
|
|
277
338
|
// HMAC-guarded machine ingest — NOT behind requirePublisher (sibling of foundryCallback).
|
|
278
339
|
async ingest(ctx) {
|
|
279
340
|
const hmacSecret = await resolveIngestSecret(ctx.env)
|
|
280
|
-
if (!hmacSecret)
|
|
341
|
+
if (!hmacSecret)
|
|
342
|
+
return json(
|
|
343
|
+
{
|
|
344
|
+
error: `${ctx.env?.INSIGHTS_INGEST_HMAC_SECRET === undefined ? 'CMS_FOUNDRY_HMAC_SECRET' : 'INSIGHTS_INGEST_HMAC_SECRET'} is not configured`,
|
|
345
|
+
},
|
|
346
|
+
500,
|
|
347
|
+
)
|
|
281
348
|
|
|
282
349
|
let rawBody: string
|
|
283
350
|
try {
|
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,5 +1,6 @@
|
|
|
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'
|
|
4
5
|
import { PublicationCurrentSnapshotError } from '../engine/publication-current-guard.js'
|
|
5
6
|
import { PublishContentValidationError } from '../engine/publisher.js'
|
|
@@ -9,7 +10,7 @@ import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.j
|
|
|
9
10
|
import { type ContentRouteConfig, createContentRoutes } from './content.js'
|
|
10
11
|
import { json, type RouteContext } from './context.js'
|
|
11
12
|
import { markFoundryMachineContext } from './machine-context.js'
|
|
12
|
-
import { insertMediaAsset } from './media-lib.js'
|
|
13
|
+
import { insertMediaAsset, readReadyImagePublicUrl } from './media-lib.js'
|
|
13
14
|
|
|
14
15
|
const SOURCE_PREFIX = 'foundry:'
|
|
15
16
|
|
|
@@ -117,7 +118,10 @@ async function authenticate(
|
|
|
117
118
|
// host-bound foundry-cms-v2 string with this publication's CONFIGURED
|
|
118
119
|
// canonical host when it opted in (M3-18), else the legacy dot string,
|
|
119
120
|
// byte-for-byte as before.
|
|
120
|
-
const
|
|
121
|
+
const url = new URL(ctx.request.url)
|
|
122
|
+
const path =
|
|
123
|
+
url.pathname +
|
|
124
|
+
(ctx.request.method === 'GET' && url.searchParams.has('working') ? url.search : '')
|
|
121
125
|
const verified = await verifyFoundryRequest(secret, {
|
|
122
126
|
method: ctx.request.method,
|
|
123
127
|
host: canonicalHost,
|
|
@@ -263,6 +267,9 @@ async function mapUpdateConflict(response: Response, id: string): Promise<Respon
|
|
|
263
267
|
if (response.status !== 409) return response
|
|
264
268
|
try {
|
|
265
269
|
const payload = (await response.clone().json()) as { error?: unknown; state?: unknown }
|
|
270
|
+
if (payload.error === 'update_canonical_conflict') {
|
|
271
|
+
return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
272
|
+
}
|
|
266
273
|
if (payload.error === 'update_status_conflict' && typeof payload.state === 'string') {
|
|
267
274
|
return approvalConflict(id, payload.state)
|
|
268
275
|
}
|
|
@@ -470,6 +477,7 @@ async function bindAdoption(
|
|
|
470
477
|
config: ContentRouteConfig,
|
|
471
478
|
id: string,
|
|
472
479
|
candidate: ContentCandidate,
|
|
480
|
+
expectedVersion?: number,
|
|
473
481
|
): Promise<boolean> {
|
|
474
482
|
const sourceId = `${SOURCE_PREFIX}${id}`
|
|
475
483
|
await ctx.db.batch([
|
|
@@ -477,9 +485,17 @@ async function bindAdoption(
|
|
|
477
485
|
.prepare(
|
|
478
486
|
`UPDATE content_items SET source_id = ?, updated_at = unixepoch()
|
|
479
487
|
WHERE id = ? AND deleted_at IS NULL AND type = 'article' AND source_id = ?
|
|
480
|
-
AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)
|
|
488
|
+
AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)
|
|
489
|
+
AND (? IS NULL OR canonical_version = ?)`,
|
|
481
490
|
)
|
|
482
|
-
.bind(
|
|
491
|
+
.bind(
|
|
492
|
+
sourceId,
|
|
493
|
+
candidate.id,
|
|
494
|
+
candidate.source_id,
|
|
495
|
+
candidate.id,
|
|
496
|
+
expectedVersion ?? null,
|
|
497
|
+
expectedVersion ?? null,
|
|
498
|
+
),
|
|
483
499
|
ctx.db
|
|
484
500
|
.prepare(
|
|
485
501
|
`INSERT INTO cms_activity_log (id, workspace_id, user_id, action, target_id, target_title, type)
|
|
@@ -518,6 +534,27 @@ async function canonicalVersion(ctx: RouteContext, contentId: string): Promise<n
|
|
|
518
534
|
return row?.canonical_version ?? null
|
|
519
535
|
}
|
|
520
536
|
|
|
537
|
+
/** A signed improvement names the actual immutable CMS revision it read. */
|
|
538
|
+
async function sourceRevisionVersion(
|
|
539
|
+
ctx: RouteContext,
|
|
540
|
+
contentId: string,
|
|
541
|
+
revisionId: string,
|
|
542
|
+
): Promise<number | null> {
|
|
543
|
+
const revision = await getRevision(ctx.db, contentId, revisionId)
|
|
544
|
+
if (!revision) return null
|
|
545
|
+
const item = revision.payload.item as Record<string, unknown> | undefined
|
|
546
|
+
const version = item?.canonical_version
|
|
547
|
+
if (
|
|
548
|
+
item?.id !== contentId ||
|
|
549
|
+
item.type !== 'article' ||
|
|
550
|
+
typeof version !== 'number' ||
|
|
551
|
+
!Number.isSafeInteger(version) ||
|
|
552
|
+
version < 1
|
|
553
|
+
)
|
|
554
|
+
return null
|
|
555
|
+
return (await canonicalVersion(ctx, contentId)) === version ? version : null
|
|
556
|
+
}
|
|
557
|
+
|
|
521
558
|
/**
|
|
522
559
|
* Record what this v2 PUT wrote as a History revision (the autosave/restore
|
|
523
560
|
* mechanism; it never moves the published pointer), created_by `foundry` and
|
|
@@ -571,6 +608,67 @@ async function lastFoundryWriteSnapshot(
|
|
|
571
608
|
return revision ? { ...revision.payload } : null
|
|
572
609
|
}
|
|
573
610
|
|
|
611
|
+
const WorkingArticleSourceSchema = z
|
|
612
|
+
.object({
|
|
613
|
+
schema: z.literal('cms.foundry-working-article.v1'),
|
|
614
|
+
content_id: z.string().min(1),
|
|
615
|
+
canonical_version: z.number().int().nonnegative(),
|
|
616
|
+
title: z.string().min(1),
|
|
617
|
+
description: z.string().nullable(),
|
|
618
|
+
slug: z.string().min(1),
|
|
619
|
+
body_html: z.string().min(1).max(2_000_000),
|
|
620
|
+
hero_image_id: z.string().nullable(),
|
|
621
|
+
hero_image_url: z.string().url().nullable(),
|
|
622
|
+
metadata: ContentMetadataSchema,
|
|
623
|
+
})
|
|
624
|
+
.strict()
|
|
625
|
+
|
|
626
|
+
async function workingArticleSource(ctx: RouteContext, row: FoundryArticleRow): Promise<Response> {
|
|
627
|
+
const snapshot = await lastFoundryWriteSnapshot(ctx, row)
|
|
628
|
+
if (!snapshot)
|
|
629
|
+
return json({ error: 'foundry_working_revision_changed', external_id: row.external_id }, 409)
|
|
630
|
+
const item = snapshot.item as Record<string, unknown>
|
|
631
|
+
const content = snapshot.content as Record<string, unknown>
|
|
632
|
+
let metadata: unknown
|
|
633
|
+
try {
|
|
634
|
+
metadata = JSON.parse(String(item.metadata_json ?? '{}'))
|
|
635
|
+
} catch {
|
|
636
|
+
return json({ error: 'working_source_metadata_invalid' }, 409)
|
|
637
|
+
}
|
|
638
|
+
const recorded = ContentMetadataSchema.safeParse(metadata)
|
|
639
|
+
if (!recorded.success) return json({ error: 'working_source_metadata_invalid' }, 409)
|
|
640
|
+
const allowed = new Set([
|
|
641
|
+
'preparation',
|
|
642
|
+
'seoDescription',
|
|
643
|
+
'credits',
|
|
644
|
+
'imageReview',
|
|
645
|
+
'faq',
|
|
646
|
+
'captionMoments',
|
|
647
|
+
'videoId',
|
|
648
|
+
'cmsSource',
|
|
649
|
+
'strategyWork',
|
|
650
|
+
'sourceRef',
|
|
651
|
+
'sourceRevisionId',
|
|
652
|
+
'adaptedAt',
|
|
653
|
+
'updatedAt',
|
|
654
|
+
'videoPublishedAt',
|
|
655
|
+
])
|
|
656
|
+
const result = WorkingArticleSourceSchema.safeParse({
|
|
657
|
+
schema: 'cms.foundry-working-article.v1',
|
|
658
|
+
content_id: row.content_id,
|
|
659
|
+
canonical_version: item.canonical_version,
|
|
660
|
+
title: item.title,
|
|
661
|
+
description: item.description ?? null,
|
|
662
|
+
slug: item.slug,
|
|
663
|
+
body_html: content?.body_html,
|
|
664
|
+
hero_image_id: item.hero_image_id ?? null,
|
|
665
|
+
hero_image_url: await readReadyImagePublicUrl(ctx.db, item.hero_image_id),
|
|
666
|
+
metadata: Object.fromEntries(Object.entries(recorded.data).filter(([key]) => allowed.has(key))),
|
|
667
|
+
})
|
|
668
|
+
if (!result.success) return json({ error: 'foundry_working_source_invalid' }, 409)
|
|
669
|
+
return json({ ...v2Receipt(row), working: result.data })
|
|
670
|
+
}
|
|
671
|
+
|
|
574
672
|
/** The v2 receipt: the four v1 fields plus the stored request hash. */
|
|
575
673
|
function v2Receipt(
|
|
576
674
|
row: Pick<FoundryArticleRow, 'external_id' | 'receipt_id' | 'status' | 'slug' | 'request_sha256'>,
|
|
@@ -595,6 +693,29 @@ async function putV2(
|
|
|
595
693
|
const body = parsed.data
|
|
596
694
|
|
|
597
695
|
const prior = await findArticle(ctx, id)
|
|
696
|
+
let expectedWorkingVersion: number | undefined
|
|
697
|
+
if (body.expectedRequestSHA256) {
|
|
698
|
+
if (
|
|
699
|
+
!prior ||
|
|
700
|
+
(prior.request_sha256 !== body.expectedRequestSHA256 && prior.request_sha256 !== bodyHash)
|
|
701
|
+
)
|
|
702
|
+
return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
|
|
703
|
+
const snapshot = await lastFoundryWriteSnapshot(ctx, prior)
|
|
704
|
+
if (!snapshot) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
705
|
+
const version = (snapshot.item as Record<string, unknown>).canonical_version
|
|
706
|
+
// Idempotent replay still requires the exact machine snapshot to be current.
|
|
707
|
+
if (prior.request_sha256 !== bodyHash && version !== body.expectedCanonicalVersion)
|
|
708
|
+
return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
|
|
709
|
+
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1)
|
|
710
|
+
return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
711
|
+
expectedWorkingVersion = version
|
|
712
|
+
}
|
|
713
|
+
if (body.createOnly && prior && (prior.request_sha256 !== bodyHash || prior.status !== 'draft')) {
|
|
714
|
+
return json(
|
|
715
|
+
{ error: 'article_already_exists', external_id: id, content_id: prior.content_id },
|
|
716
|
+
409,
|
|
717
|
+
)
|
|
718
|
+
}
|
|
598
719
|
if (prior?.request_sha256 === bodyHash) {
|
|
599
720
|
console.info('[cms] foundry article request deduplicated', {
|
|
600
721
|
externalId: id,
|
|
@@ -610,8 +731,41 @@ async function putV2(
|
|
|
610
731
|
// Scheduled, review and archived items stay human-owned.
|
|
611
732
|
if (bound && !V2_WRITABLE.has(bound.status)) return approvalConflict(id, bound.status)
|
|
612
733
|
|
|
734
|
+
if (body.createOnly) {
|
|
735
|
+
if (body.expectedRevisionId) return json({ error: 'create_only_has_source_revision' }, 400)
|
|
736
|
+
if (body.adoptSourceId || bound)
|
|
737
|
+
return json({ error: 'article_already_exists', external_id: id }, 409)
|
|
738
|
+
const slugHolder = await findCandidate(ctx, 'slug', body.slug)
|
|
739
|
+
if (slugHolder) return slugConflict(id, body.slug, slugHolder.id)
|
|
740
|
+
const videoId = body.metadata?.videoId
|
|
741
|
+
if (typeof videoId !== 'string' || !/^[A-Za-z0-9_-]{11}$/.test(videoId))
|
|
742
|
+
return json({ error: 'create_only_requires_source_video' }, 400)
|
|
743
|
+
// Check the actual CMS working and immutable published projections, not
|
|
744
|
+
// just Foundry's own relationship ledger. A creation cannot adopt or edit.
|
|
745
|
+
const associated = await ctx.db
|
|
746
|
+
.prepare(`SELECT c.id FROM content_items c
|
|
747
|
+
LEFT JOIN content_revisions r ON r.id=c.published_revision_id AND r.content_id=c.id
|
|
748
|
+
WHERE c.type='article' AND c.deleted_at IS NULL AND (
|
|
749
|
+
CASE WHEN json_valid(c.metadata_json) THEN json_extract(c.metadata_json,'$.videoId') END = ?
|
|
750
|
+
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 = ?
|
|
751
|
+
) LIMIT 1`)
|
|
752
|
+
.bind(videoId, videoId)
|
|
753
|
+
.first<{ id: string }>()
|
|
754
|
+
if (associated)
|
|
755
|
+
return json(
|
|
756
|
+
{ error: 'article_video_already_exists', external_id: id, content_id: associated.id },
|
|
757
|
+
409,
|
|
758
|
+
)
|
|
759
|
+
}
|
|
613
760
|
let adoption: Adoption | null = null
|
|
761
|
+
let expectedSourceVersion: number | undefined = expectedWorkingVersion
|
|
614
762
|
if (bound) {
|
|
763
|
+
if (body.expectedRevisionId) {
|
|
764
|
+
const version = await sourceRevisionVersion(ctx, bound.id, body.expectedRevisionId)
|
|
765
|
+
if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
766
|
+
expectedSourceVersion = version
|
|
767
|
+
}
|
|
768
|
+
|
|
615
769
|
const holder = await findCandidate(ctx, 'slug', body.slug)
|
|
616
770
|
if (holder && holder.id !== bound.id) return slugConflict(id, body.slug, holder.id)
|
|
617
771
|
} else {
|
|
@@ -619,7 +773,12 @@ async function putV2(
|
|
|
619
773
|
if ('error' in resolved) return resolved.error
|
|
620
774
|
if (resolved.candidate && resolved.matchedBy) {
|
|
621
775
|
const candidate = resolved.candidate
|
|
622
|
-
if (
|
|
776
|
+
if (body.expectedRevisionId) {
|
|
777
|
+
const version = await sourceRevisionVersion(ctx, candidate.id, body.expectedRevisionId)
|
|
778
|
+
if (version === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
779
|
+
expectedSourceVersion = version
|
|
780
|
+
}
|
|
781
|
+
if (!(await bindAdoption(ctx, config, id, candidate, expectedSourceVersion))) {
|
|
623
782
|
return json(
|
|
624
783
|
{
|
|
625
784
|
error: 'adoption_conflict',
|
|
@@ -656,11 +815,44 @@ async function putV2(
|
|
|
656
815
|
}
|
|
657
816
|
}
|
|
658
817
|
|
|
659
|
-
//
|
|
660
|
-
|
|
818
|
+
// Preserve a newer human working copy before PUT, not merely at publication.
|
|
819
|
+
// The native update pins this same canonical version in its atomic batch.
|
|
820
|
+
if (body.expectedRevisionId && !bound)
|
|
821
|
+
return json({ error: 'source_revision_missing', external_id: id }, 409)
|
|
822
|
+
const priorVersion =
|
|
823
|
+
expectedSourceVersion ?? (bound ? await canonicalVersion(ctx, bound.id) : null)
|
|
661
824
|
const heroImageId = body.hero_image_url
|
|
662
825
|
? await heroAssetId(body.hero_image_url)
|
|
663
826
|
: (body.hero_image_key ?? undefined)
|
|
827
|
+
// A pinned improvement augments its exact source metadata. Existing dates,
|
|
828
|
+
// image provenance and other recorded fields survive preparation updates.
|
|
829
|
+
let metadata = body.metadata
|
|
830
|
+
if (
|
|
831
|
+
(body.expectedRevisionId || body.expectedRequestSHA256) &&
|
|
832
|
+
body.metadata !== undefined &&
|
|
833
|
+
bound
|
|
834
|
+
) {
|
|
835
|
+
const source = body.expectedRevisionId
|
|
836
|
+
? (await getRevision(ctx.db, bound.id, body.expectedRevisionId))?.payload
|
|
837
|
+
: prior
|
|
838
|
+
? await lastFoundryWriteSnapshot(ctx, prior)
|
|
839
|
+
: null
|
|
840
|
+
let original: unknown
|
|
841
|
+
try {
|
|
842
|
+
original = JSON.parse(
|
|
843
|
+
String((source?.item as Record<string, unknown> | undefined)?.metadata_json ?? '{}'),
|
|
844
|
+
)
|
|
845
|
+
} catch {
|
|
846
|
+
return json({ error: 'source_metadata_invalid', external_id: id }, 409)
|
|
847
|
+
}
|
|
848
|
+
const recorded = ContentMetadataSchema.safeParse(original)
|
|
849
|
+
if (!recorded.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
|
|
850
|
+
const merged = ContentMetadataSchema.safeParse(
|
|
851
|
+
mergePinnedArticleMetadata(recorded.data, body.metadata),
|
|
852
|
+
)
|
|
853
|
+
if (!merged.success) return json({ error: 'source_metadata_invalid', external_id: id }, 409)
|
|
854
|
+
metadata = merged.data
|
|
855
|
+
}
|
|
664
856
|
const nativeBody = {
|
|
665
857
|
title: body.title,
|
|
666
858
|
slug: body.slug,
|
|
@@ -672,7 +864,7 @@ async function putV2(
|
|
|
672
864
|
...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
|
|
673
865
|
...(body.tags !== undefined ? { tags: body.tags } : {}),
|
|
674
866
|
...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
|
|
675
|
-
...(
|
|
867
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
676
868
|
content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
|
|
677
869
|
}
|
|
678
870
|
|
|
@@ -681,7 +873,12 @@ async function putV2(
|
|
|
681
873
|
// Each write is a CAS on the status it was read in: a draft stays a
|
|
682
874
|
// draft write, and a published item is edited in place only while it is
|
|
683
875
|
// still published (its live revision changes only on POST /publish).
|
|
684
|
-
|
|
876
|
+
if (priorVersion === null) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
877
|
+
const writer = createContentRoutes({
|
|
878
|
+
...machineConfig(config),
|
|
879
|
+
updateStatusConstraint: bound.status,
|
|
880
|
+
updateCanonicalVersionConstraint: priorVersion,
|
|
881
|
+
})
|
|
685
882
|
nativeResponse = await mapUpdateConflict(
|
|
686
883
|
await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
|
|
687
884
|
id,
|
|
@@ -698,6 +895,8 @@ async function putV2(
|
|
|
698
895
|
)
|
|
699
896
|
} catch (error) {
|
|
700
897
|
const raced = await findContentBySource(ctx, id)
|
|
898
|
+
if (body.createOnly && raced)
|
|
899
|
+
return json({ error: 'article_already_exists', external_id: id, content_id: raced.id }, 409)
|
|
701
900
|
if (raced?.type !== 'article') throw error
|
|
702
901
|
nativeResponse = await mapUpdateConflict(
|
|
703
902
|
await handlers.draft.update(nativeContext(ctx, 'PATCH', nativeBody, { id: raced.id })),
|
|
@@ -1028,6 +1227,13 @@ export function createFoundryPublishRoutes(
|
|
|
1028
1227
|
if ('error' in auth) return auth.error
|
|
1029
1228
|
const row = await findArticle(ctx, id)
|
|
1030
1229
|
if (!row) return json({ error: 'not_found' }, 404)
|
|
1230
|
+
const working = new URL(ctx.request.url).searchParams.get('working')
|
|
1231
|
+
if (working !== null) {
|
|
1232
|
+
if (working !== '1') return json({ error: 'invalid_working_source_request' }, 400)
|
|
1233
|
+
if (!autoPublishEnabled(config))
|
|
1234
|
+
return json({ error: 'native_working_source_unavailable' }, 409)
|
|
1235
|
+
return workingArticleSource(ctx, row)
|
|
1236
|
+
}
|
|
1031
1237
|
console.info('[cms] foundry article read', { externalId: id, receiptId: row.receipt_id })
|
|
1032
1238
|
if (autoPublishEnabled(config)) return json(v2Receipt(row))
|
|
1033
1239
|
return json(receipt(row))
|
|
@@ -60,7 +60,10 @@ import { json, type RouteContext } from './context.js'
|
|
|
60
60
|
// family rather than inventing a second one.
|
|
61
61
|
export async function resolveFoundryHmacSecret(
|
|
62
62
|
env: Record<string, unknown> | undefined,
|
|
63
|
-
binding:
|
|
63
|
+
binding:
|
|
64
|
+
| 'FOUNDRY_HMAC_SECRET'
|
|
65
|
+
| 'CMS_FOUNDRY_HMAC_SECRET'
|
|
66
|
+
| 'INSIGHTS_INGEST_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
|
|
64
67
|
): Promise<string | null> {
|
|
65
68
|
const raw = env?.[binding]
|
|
66
69
|
if (typeof raw === 'string') return raw || null
|
package/src/routes/media-lib.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// The `media.fronts.co/cdn-cgi/image` transform doc-comment + editorial-max
|
|
10
10
|
// constants are theme-token — kept here as documentation only.
|
|
11
11
|
|
|
12
|
+
import { z } from 'zod'
|
|
12
13
|
import type { D1Database } from '../engine/d1.js'
|
|
13
14
|
import { slugify } from '../engine/slug.js'
|
|
14
15
|
|
|
@@ -18,6 +19,23 @@ import { slugify } from '../engine/slug.js'
|
|
|
18
19
|
|
|
19
20
|
const MEDIA_DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i
|
|
20
21
|
|
|
22
|
+
/** Resolve the selected ready image; a legacy key or unavailable asset stays unknown. */
|
|
23
|
+
export async function readReadyImagePublicUrl(db: D1Database, id: unknown): Promise<string | null> {
|
|
24
|
+
if (typeof id !== 'string' || id === '') return null
|
|
25
|
+
const asset = await db
|
|
26
|
+
.prepare(
|
|
27
|
+
"SELECT public_url FROM media_assets WHERE id = ? AND asset_type = 'image' AND status = 'ready'",
|
|
28
|
+
)
|
|
29
|
+
.bind(id)
|
|
30
|
+
.first<{ public_url: string | null }>()
|
|
31
|
+
const url = z.string().url().safeParse(asset?.public_url)
|
|
32
|
+
if (!url.success) return null
|
|
33
|
+
const parsed = new URL(url.data)
|
|
34
|
+
return parsed.protocol === 'https:' && !parsed.username && !parsed.password && !parsed.hash
|
|
35
|
+
? url.data
|
|
36
|
+
: null
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
/**
|
|
22
40
|
* Resolve the public media domain, preferring `env.MEDIA_PUBLIC_DOMAIN` when it
|
|
23
41
|
* looks like a valid host, else the site-configured default. Allows
|
|
@@ -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)
|