@growth-labs/cms 0.8.35 → 0.8.40
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/fronts-publish.d.ts +29 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +58 -0
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +19 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +75 -0
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publisher.d.ts +3 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +15 -2
- package/dist/engine/publisher.js.map +1 -1
- package/dist/foundry-emit.d.ts +106 -4
- package/dist/foundry-emit.d.ts.map +1 -1
- package/dist/foundry-emit.js +28 -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 +18 -28
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/content.d.ts +3 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +1 -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 +191 -8
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +3 -1
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +47 -1
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/routes/media-lib.d.ts +4 -0
- package/dist/routes/media-lib.d.ts.map +1 -1
- package/dist/routes/media-lib.js +23 -0
- package/dist/routes/media-lib.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +88 -88
- package/dist/ui/api/fronts/publish-callback.d.ts +1 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
- package/dist/ui/api/fronts/publish-callback.js +1 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -1
- package/dist/ui/editor/extensions.d.ts +1 -1
- package/dist/ui/editor/extensions.d.ts.map +1 -1
- package/dist/ui/editor/extensions.js +15 -1
- package/dist/ui/editor/extensions.js.map +1 -1
- package/dist/ui/editor/heading-level.d.ts +4 -0
- package/dist/ui/editor/heading-level.d.ts.map +1 -0
- package/dist/ui/editor/heading-level.js +13 -0
- package/dist/ui/editor/heading-level.js.map +1 -0
- package/dist/ui/editor/portable-text.d.ts.map +1 -1
- package/dist/ui/editor/portable-text.js +3 -2
- package/dist/ui/editor/portable-text.js.map +1 -1
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +4 -3
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/preview/SitePreviewFrame.d.ts +11 -0
- package/dist/ui/preview/SitePreviewFrame.d.ts.map +1 -0
- package/dist/ui/preview/SitePreviewFrame.js +86 -0
- package/dist/ui/preview/SitePreviewFrame.js.map +1 -0
- package/dist/ui/preview/preview-layout.d.ts +6 -0
- package/dist/ui/preview/preview-layout.d.ts.map +1 -1
- package/dist/ui/preview/preview-layout.js +15 -0
- package/dist/ui/preview/preview-layout.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +61 -25
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/public-url.d.ts +12 -0
- package/dist/ui/screens/public-url.d.ts.map +1 -1
- package/dist/ui/screens/public-url.js +30 -0
- package/dist/ui/screens/public-url.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/fronts-publish.ts +88 -0
- package/src/engine/publication-current-guard.ts +103 -0
- package/src/engine/publisher.ts +22 -3
- package/src/foundry-emit.ts +36 -0
- package/src/routes/content-insights.ts +23 -28
- package/src/routes/content.ts +4 -0
- package/src/routes/foundry-publish.ts +228 -8
- package/src/routes/fronts-publish.ts +61 -1
- package/src/routes/media-lib.ts +24 -0
- package/src/ui/api/fronts/publish-callback.ts +3 -0
- package/src/ui/editor/extensions.ts +16 -1
- package/src/ui/editor/heading-level.ts +14 -0
- package/src/ui/editor/portable-text.ts +3 -2
- package/src/ui/editor/serialize.ts +4 -3
- package/src/ui/preview/SitePreviewFrame.tsx +111 -0
- package/src/ui/preview/preview-layout.ts +17 -0
- package/src/ui/screens/EditorScreen.tsx +76 -27
- package/src/ui/screens/public-url.ts +36 -0
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
resolveFoundryHmacSecret as resolveFoundryHmacSecretForInputs,
|
|
26
26
|
verifyFoundrySignedRequest,
|
|
27
27
|
} from './fronts-publish.js'
|
|
28
|
+
import { readReadyImagePublicUrl } from './media-lib.js'
|
|
28
29
|
|
|
29
30
|
const DismissSchema = z
|
|
30
31
|
.object({
|
|
@@ -67,6 +68,10 @@ const PublishedArticleSourceSchema = z
|
|
|
67
68
|
site: z.string().min(1),
|
|
68
69
|
observed_at: z.string().datetime(),
|
|
69
70
|
article: PublishedArticleStateSchema.extend({
|
|
71
|
+
publication_receipt_identity: z
|
|
72
|
+
.string()
|
|
73
|
+
.regex(/^[a-f0-9]{64}$/)
|
|
74
|
+
.nullable(),
|
|
70
75
|
hero_image_url: z.string().url().nullable(),
|
|
71
76
|
body_markdown: z.string().min(1).max(1_000_000),
|
|
72
77
|
description: z.string().nullable(),
|
|
@@ -111,17 +116,8 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
|
|
|
111
116
|
if (!content || content.type !== 'article')
|
|
112
117
|
return json({ error: 'published_article_missing' }, 404)
|
|
113
118
|
const item = content.payload.item
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
}
|
|
119
|
+
const snapshot = content.payload as unknown as Record<string, unknown>
|
|
120
|
+
const heroImageUrl = await readReadyImagePublicUrl(ctx.db, item.hero_image_id)
|
|
125
121
|
const result = PublishedArticleSourceSchema.safeParse({
|
|
126
122
|
ok: true,
|
|
127
123
|
schema: 'cms.published-article-source.v1',
|
|
@@ -130,6 +126,7 @@ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Pro
|
|
|
130
126
|
article: {
|
|
131
127
|
content_id: content.contentId,
|
|
132
128
|
revision_id: content.revisionId,
|
|
129
|
+
publication_receipt_identity: snapshot.publication_receipt_identity ?? null,
|
|
133
130
|
slug: content.slug,
|
|
134
131
|
title: item.title,
|
|
135
132
|
canonical_url: item.canonical_url ?? null,
|
|
@@ -215,7 +212,7 @@ function resolveRunDate(ctx: RouteContext): string {
|
|
|
215
212
|
}
|
|
216
213
|
|
|
217
214
|
/**
|
|
218
|
-
* Resolve the
|
|
215
|
+
* Resolve the explicit legacy ingest binding, or the declared shared CMS machine binding for channel publications.
|
|
219
216
|
* Supports both:
|
|
220
217
|
* - plain string (test injection: env: { INSIGHTS_INGEST_HMAC_SECRET: 'test-...' })
|
|
221
218
|
* - Secrets Store binding object { get(): Promise<string> } (production Workers binding)
|
|
@@ -224,21 +221,13 @@ function resolveRunDate(ctx: RouteContext): string {
|
|
|
224
221
|
async function resolveIngestSecret(
|
|
225
222
|
env: Record<string, unknown> | undefined,
|
|
226
223
|
): Promise<string | null> {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
)
|
|
234
|
-
try {
|
|
235
|
-
const val = await (raw as { get(): Promise<string> }).get()
|
|
236
|
-
if (typeof val === 'string' && val.length > 0) return val
|
|
237
|
-
} catch {
|
|
238
|
-
return null
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return null
|
|
224
|
+
// Preserve explicit legacy ingest credentials, including their failures.
|
|
225
|
+
// Channel publications already declare the shared CMS machine binding.
|
|
226
|
+
const binding =
|
|
227
|
+
env?.INSIGHTS_INGEST_HMAC_SECRET === undefined
|
|
228
|
+
? 'CMS_FOUNDRY_HMAC_SECRET'
|
|
229
|
+
: 'INSIGHTS_INGEST_HMAC_SECRET'
|
|
230
|
+
return resolveFoundryHmacSecretForInputs(env, binding)
|
|
242
231
|
}
|
|
243
232
|
|
|
244
233
|
export interface ContentInsightsRouteConfig extends CmsRouteConfig {
|
|
@@ -355,7 +344,13 @@ export function createContentInsightsRoutes(
|
|
|
355
344
|
// HMAC-guarded machine ingest — NOT behind requirePublisher (sibling of foundryCallback).
|
|
356
345
|
async ingest(ctx) {
|
|
357
346
|
const hmacSecret = await resolveIngestSecret(ctx.env)
|
|
358
|
-
if (!hmacSecret)
|
|
347
|
+
if (!hmacSecret)
|
|
348
|
+
return json(
|
|
349
|
+
{
|
|
350
|
+
error: `${ctx.env?.INSIGHTS_INGEST_HMAC_SECRET === undefined ? 'CMS_FOUNDRY_HMAC_SECRET' : 'INSIGHTS_INGEST_HMAC_SECRET'} is not configured`,
|
|
351
|
+
},
|
|
352
|
+
500,
|
|
353
|
+
)
|
|
359
354
|
|
|
360
355
|
let rawBody: string
|
|
361
356
|
try {
|
package/src/routes/content.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
setFrontsChannelTargets,
|
|
36
36
|
} from '../engine/fronts-publish-intent.js'
|
|
37
37
|
import { classifyGoogleDriveUrl } from '../engine/google-drive-source.js'
|
|
38
|
+
import type { NativeArticleDraftSnapshot } from '../engine/publication-current-guard.js'
|
|
38
39
|
import { evaluateArticleBody } from '../engine/publish-guard.js'
|
|
39
40
|
import {
|
|
40
41
|
createContent,
|
|
@@ -237,6 +238,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
237
238
|
updateStatusConstraint?: ContentStatus
|
|
238
239
|
/** Internal machine-route pin; joins the existing status-constrained atomic batch. */
|
|
239
240
|
updateCanonicalVersionConstraint?: number
|
|
241
|
+
/** Exact native draft input; joins the status/version constrained batch. */
|
|
242
|
+
updateNativeDraftSnapshotConstraint?: NativeArticleDraftSnapshot
|
|
240
243
|
/**
|
|
241
244
|
* Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
|
|
242
245
|
* commit step (publishContent + createRevision) with the caller's — publishOne
|
|
@@ -1810,6 +1813,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1810
1813
|
updated = await updateContentItem(ctx.db, id, parsed.data, {
|
|
1811
1814
|
requiredStatus: config.updateStatusConstraint,
|
|
1812
1815
|
expectedCanonicalVersion: config.updateCanonicalVersionConstraint,
|
|
1816
|
+
expectedNativeDraftSnapshot: config.updateNativeDraftSnapshotConstraint,
|
|
1813
1817
|
...(expectedMediaSource ? { expectedMediaSource } : {}),
|
|
1814
1818
|
})
|
|
1815
1819
|
} catch (error) {
|
|
@@ -2,15 +2,22 @@ import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/f
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { ContentMetadataSchema, mergePinnedArticleMetadata } from '../engine/content-metadata.js'
|
|
4
4
|
import { type PublicationReconciler, publishOne } from '../engine/publication.js'
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
type NativeArticleDraftSnapshot,
|
|
7
|
+
PublicationCurrentSnapshotError,
|
|
8
|
+
} from '../engine/publication-current-guard.js'
|
|
9
|
+
import {
|
|
10
|
+
canonicalJsonSha256,
|
|
11
|
+
type JsonValue,
|
|
12
|
+
} from '../engine/publication-receipt/canonical-json.js'
|
|
13
|
+
import { getContentSnapshot, PublishContentValidationError } from '../engine/publisher.js'
|
|
7
14
|
import { createRevisionWithDelta, getRevision } from '../engine/revisions.js'
|
|
8
15
|
import { buildContentPath, slugify } from '../engine/slug.js'
|
|
9
16
|
import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.js'
|
|
10
17
|
import { type ContentRouteConfig, createContentRoutes } from './content.js'
|
|
11
18
|
import { json, type RouteContext } from './context.js'
|
|
12
19
|
import { markFoundryMachineContext } from './machine-context.js'
|
|
13
|
-
import { insertMediaAsset } from './media-lib.js'
|
|
20
|
+
import { insertMediaAsset, readReadyImagePublicUrl, readyImagePublicUrl } from './media-lib.js'
|
|
14
21
|
|
|
15
22
|
const SOURCE_PREFIX = 'foundry:'
|
|
16
23
|
|
|
@@ -118,7 +125,10 @@ async function authenticate(
|
|
|
118
125
|
// host-bound foundry-cms-v2 string with this publication's CONFIGURED
|
|
119
126
|
// canonical host when it opted in (M3-18), else the legacy dot string,
|
|
120
127
|
// byte-for-byte as before.
|
|
121
|
-
const
|
|
128
|
+
const url = new URL(ctx.request.url)
|
|
129
|
+
const path =
|
|
130
|
+
url.pathname +
|
|
131
|
+
(ctx.request.method === 'GET' && url.searchParams.has('working') ? url.search : '')
|
|
122
132
|
const verified = await verifyFoundryRequest(secret, {
|
|
123
133
|
method: ctx.request.method,
|
|
124
134
|
host: canonicalHost,
|
|
@@ -605,6 +615,147 @@ async function lastFoundryWriteSnapshot(
|
|
|
605
615
|
return revision ? { ...revision.payload } : null
|
|
606
616
|
}
|
|
607
617
|
|
|
618
|
+
const WorkingArticleSourceSchema = z
|
|
619
|
+
.object({
|
|
620
|
+
schema: z.literal('cms.foundry-working-article.v1'),
|
|
621
|
+
content_id: z.string().min(1),
|
|
622
|
+
canonical_version: z.number().int().nonnegative(),
|
|
623
|
+
title: z.string().min(1),
|
|
624
|
+
description: z.string().nullable(),
|
|
625
|
+
slug: z.string().min(1),
|
|
626
|
+
body_html: z.string().min(1).max(2_000_000),
|
|
627
|
+
hero_image_id: z.string().nullable(),
|
|
628
|
+
hero_image_url: z.string().url().nullable(),
|
|
629
|
+
metadata: ContentMetadataSchema,
|
|
630
|
+
})
|
|
631
|
+
.strict()
|
|
632
|
+
|
|
633
|
+
async function workingArticleSource(ctx: RouteContext, row: FoundryArticleRow): Promise<Response> {
|
|
634
|
+
const snapshot = await lastFoundryWriteSnapshot(ctx, row)
|
|
635
|
+
if (!snapshot)
|
|
636
|
+
return json({ error: 'foundry_working_revision_changed', external_id: row.external_id }, 409)
|
|
637
|
+
const projection = await projectArticleSource(ctx, row, snapshot)
|
|
638
|
+
if (projection instanceof Response) return projection
|
|
639
|
+
return json({ ...v2Receipt(row), working: projection })
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function projectArticleSource(
|
|
643
|
+
ctx: RouteContext,
|
|
644
|
+
row: FoundryArticleRow,
|
|
645
|
+
snapshot: Record<string, unknown>,
|
|
646
|
+
): Promise<z.infer<typeof WorkingArticleSourceSchema> | Response> {
|
|
647
|
+
const item = snapshot.item as Record<string, unknown>
|
|
648
|
+
const content = snapshot.content as Record<string, unknown>
|
|
649
|
+
let metadata: unknown
|
|
650
|
+
try {
|
|
651
|
+
metadata = JSON.parse(String(item.metadata_json ?? '{}'))
|
|
652
|
+
} catch {
|
|
653
|
+
return json({ error: 'working_source_metadata_invalid' }, 409)
|
|
654
|
+
}
|
|
655
|
+
const recorded = ContentMetadataSchema.safeParse(metadata)
|
|
656
|
+
if (!recorded.success) return json({ error: 'working_source_metadata_invalid' }, 409)
|
|
657
|
+
const allowed = new Set([
|
|
658
|
+
'preparation',
|
|
659
|
+
'seoDescription',
|
|
660
|
+
'credits',
|
|
661
|
+
'imageReview',
|
|
662
|
+
'faq',
|
|
663
|
+
'captionMoments',
|
|
664
|
+
'videoId',
|
|
665
|
+
'cmsSource',
|
|
666
|
+
'strategyWork',
|
|
667
|
+
'sourceRef',
|
|
668
|
+
'sourceRevisionId',
|
|
669
|
+
'adaptedAt',
|
|
670
|
+
'updatedAt',
|
|
671
|
+
'videoPublishedAt',
|
|
672
|
+
])
|
|
673
|
+
const result = WorkingArticleSourceSchema.safeParse({
|
|
674
|
+
schema: 'cms.foundry-working-article.v1',
|
|
675
|
+
content_id: row.content_id,
|
|
676
|
+
canonical_version: item.canonical_version,
|
|
677
|
+
title: item.title,
|
|
678
|
+
description: item.description ?? null,
|
|
679
|
+
slug: item.slug,
|
|
680
|
+
body_html: content?.body_html,
|
|
681
|
+
hero_image_id: item.hero_image_id ?? null,
|
|
682
|
+
hero_image_url: Object.hasOwn(snapshot, 'hero_asset')
|
|
683
|
+
? readyImagePublicUrl(snapshot.hero_asset as Record<string, unknown> | null)
|
|
684
|
+
: await readReadyImagePublicUrl(ctx.db, item.hero_image_id),
|
|
685
|
+
metadata: Object.fromEntries(Object.entries(recorded.data).filter(([key]) => allowed.has(key))),
|
|
686
|
+
})
|
|
687
|
+
if (!result.success) return json({ error: 'foundry_working_source_invalid' }, 409)
|
|
688
|
+
return result.data
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Current input for governed preparation, never evidence of an earlier PUT. */
|
|
692
|
+
async function draftPreparationSnapshot(
|
|
693
|
+
ctx: RouteContext,
|
|
694
|
+
row: FoundryArticleRow,
|
|
695
|
+
): Promise<Record<string, unknown> | null> {
|
|
696
|
+
if (row.status !== 'draft') return null
|
|
697
|
+
const snapshot = await getContentSnapshot(ctx.db, row.content_id)
|
|
698
|
+
const item = snapshot?.item as Record<string, unknown> | undefined
|
|
699
|
+
if (
|
|
700
|
+
!(
|
|
701
|
+
snapshot &&
|
|
702
|
+
item?.id === row.content_id &&
|
|
703
|
+
item.type === 'article' &&
|
|
704
|
+
item.status === 'draft' &&
|
|
705
|
+
item.deleted_at == null &&
|
|
706
|
+
item.source_id === `${SOURCE_PREFIX}${row.external_id}` &&
|
|
707
|
+
typeof item.canonical_version === 'number' &&
|
|
708
|
+
Number.isSafeInteger(item.canonical_version) &&
|
|
709
|
+
item.canonical_version > 0
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
return null
|
|
713
|
+
const hero =
|
|
714
|
+
typeof item.hero_image_id === 'string'
|
|
715
|
+
? await ctx.db
|
|
716
|
+
.prepare('SELECT * FROM media_assets WHERE id=? LIMIT 1')
|
|
717
|
+
.bind(item.hero_image_id)
|
|
718
|
+
.first<Record<string, unknown>>()
|
|
719
|
+
: null
|
|
720
|
+
return { ...snapshot, hero_asset: hero }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function draftPreparationSource(
|
|
724
|
+
ctx: RouteContext,
|
|
725
|
+
row: FoundryArticleRow,
|
|
726
|
+
): Promise<Response> {
|
|
727
|
+
const snapshot = await draftPreparationSnapshot(ctx, row)
|
|
728
|
+
if (!snapshot) return json({ error: 'draft_preparation_source_unavailable' }, 409)
|
|
729
|
+
const projection = await projectArticleSource(ctx, row, snapshot)
|
|
730
|
+
if (projection instanceof Response) return projection
|
|
731
|
+
return json({
|
|
732
|
+
...v2Receipt(row),
|
|
733
|
+
draft_preparation: {
|
|
734
|
+
...projection,
|
|
735
|
+
schema: 'cms.foundry-draft-preparation.v1',
|
|
736
|
+
source_id: `${SOURCE_PREFIX}${row.external_id}`,
|
|
737
|
+
snapshot_sha256: await canonicalJsonSha256(
|
|
738
|
+
nativeDraftInput(snapshot, row) as unknown as JsonValue,
|
|
739
|
+
),
|
|
740
|
+
},
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function nativeDraftInput(
|
|
745
|
+
snapshot: Record<string, unknown>,
|
|
746
|
+
row: FoundryArticleRow,
|
|
747
|
+
): NativeArticleDraftSnapshot {
|
|
748
|
+
return {
|
|
749
|
+
snapshot,
|
|
750
|
+
binding: {
|
|
751
|
+
externalId: row.external_id,
|
|
752
|
+
contentId: row.content_id,
|
|
753
|
+
receiptId: row.receipt_id,
|
|
754
|
+
requestSHA256: row.request_sha256,
|
|
755
|
+
},
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
608
759
|
/** The v2 receipt: the four v1 fields plus the stored request hash. */
|
|
609
760
|
function v2Receipt(
|
|
610
761
|
row: Pick<FoundryArticleRow, 'external_id' | 'receipt_id' | 'status' | 'slug' | 'request_sha256'>,
|
|
@@ -629,6 +780,51 @@ async function putV2(
|
|
|
629
780
|
const body = parsed.data
|
|
630
781
|
|
|
631
782
|
const prior = await findArticle(ctx, id)
|
|
783
|
+
let expectedWorkingVersion: number | undefined
|
|
784
|
+
let preparedDraftSnapshot: Record<string, unknown> | null = null
|
|
785
|
+
if (body.expectedDraftSnapshot) {
|
|
786
|
+
const pin = body.expectedDraftSnapshot
|
|
787
|
+
// A replay is read-only and still requires this exact PUT's snapshot.
|
|
788
|
+
if (prior?.request_sha256 === bodyHash) {
|
|
789
|
+
if (!(await lastFoundryWriteSnapshot(ctx, prior)))
|
|
790
|
+
return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
|
|
791
|
+
return json(v2Receipt(prior))
|
|
792
|
+
}
|
|
793
|
+
if (
|
|
794
|
+
!prior ||
|
|
795
|
+
prior.content_id !== pin.contentId ||
|
|
796
|
+
prior.request_sha256 !== pin.priorRequestSHA256
|
|
797
|
+
)
|
|
798
|
+
return json({ error: 'draft_preparation_source_changed', external_id: id }, 409)
|
|
799
|
+
preparedDraftSnapshot = await draftPreparationSnapshot(ctx, prior)
|
|
800
|
+
const item = preparedDraftSnapshot?.item as Record<string, unknown> | undefined
|
|
801
|
+
if (
|
|
802
|
+
!preparedDraftSnapshot ||
|
|
803
|
+
item?.canonical_version !== pin.canonicalVersion ||
|
|
804
|
+
item?.slug !== body.slug ||
|
|
805
|
+
(await canonicalJsonSha256(
|
|
806
|
+
nativeDraftInput(preparedDraftSnapshot, prior) as unknown as JsonValue,
|
|
807
|
+
)) !== pin.sha256
|
|
808
|
+
)
|
|
809
|
+
return json({ error: 'draft_preparation_source_changed', external_id: id }, 409)
|
|
810
|
+
expectedWorkingVersion = pin.canonicalVersion
|
|
811
|
+
}
|
|
812
|
+
if (body.expectedRequestSHA256) {
|
|
813
|
+
if (
|
|
814
|
+
!prior ||
|
|
815
|
+
(prior.request_sha256 !== body.expectedRequestSHA256 && prior.request_sha256 !== bodyHash)
|
|
816
|
+
)
|
|
817
|
+
return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
|
|
818
|
+
const snapshot = await lastFoundryWriteSnapshot(ctx, prior)
|
|
819
|
+
if (!snapshot) return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
820
|
+
const version = (snapshot.item as Record<string, unknown>).canonical_version
|
|
821
|
+
// Idempotent replay still requires the exact machine snapshot to be current.
|
|
822
|
+
if (prior.request_sha256 !== bodyHash && version !== body.expectedCanonicalVersion)
|
|
823
|
+
return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
|
|
824
|
+
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1)
|
|
825
|
+
return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
826
|
+
expectedWorkingVersion = version
|
|
827
|
+
}
|
|
632
828
|
if (body.createOnly && prior && (prior.request_sha256 !== bodyHash || prior.status !== 'draft')) {
|
|
633
829
|
return json(
|
|
634
830
|
{ error: 'article_already_exists', external_id: id, content_id: prior.content_id },
|
|
@@ -677,7 +873,7 @@ async function putV2(
|
|
|
677
873
|
)
|
|
678
874
|
}
|
|
679
875
|
let adoption: Adoption | null = null
|
|
680
|
-
let expectedSourceVersion: number | undefined
|
|
876
|
+
let expectedSourceVersion: number | undefined = expectedWorkingVersion
|
|
681
877
|
if (bound) {
|
|
682
878
|
if (body.expectedRevisionId) {
|
|
683
879
|
const version = await sourceRevisionVersion(ctx, bound.id, body.expectedRevisionId)
|
|
@@ -746,11 +942,23 @@ async function putV2(
|
|
|
746
942
|
// A pinned improvement augments its exact source metadata. Existing dates,
|
|
747
943
|
// image provenance and other recorded fields survive preparation updates.
|
|
748
944
|
let metadata = body.metadata
|
|
749
|
-
if (
|
|
750
|
-
|
|
945
|
+
if (
|
|
946
|
+
(body.expectedRevisionId || body.expectedRequestSHA256 || body.expectedDraftSnapshot) &&
|
|
947
|
+
body.metadata !== undefined &&
|
|
948
|
+
bound
|
|
949
|
+
) {
|
|
950
|
+
const source =
|
|
951
|
+
preparedDraftSnapshot ??
|
|
952
|
+
(body.expectedRevisionId
|
|
953
|
+
? (await getRevision(ctx.db, bound.id, body.expectedRevisionId))?.payload
|
|
954
|
+
: prior
|
|
955
|
+
? await lastFoundryWriteSnapshot(ctx, prior)
|
|
956
|
+
: null)
|
|
751
957
|
let original: unknown
|
|
752
958
|
try {
|
|
753
|
-
original = JSON.parse(
|
|
959
|
+
original = JSON.parse(
|
|
960
|
+
String((source?.item as Record<string, unknown> | undefined)?.metadata_json ?? '{}'),
|
|
961
|
+
)
|
|
754
962
|
} catch {
|
|
755
963
|
return json({ error: 'source_metadata_invalid', external_id: id }, 409)
|
|
756
964
|
}
|
|
@@ -787,6 +995,9 @@ async function putV2(
|
|
|
787
995
|
...machineConfig(config),
|
|
788
996
|
updateStatusConstraint: bound.status,
|
|
789
997
|
updateCanonicalVersionConstraint: priorVersion,
|
|
998
|
+
...(preparedDraftSnapshot && prior
|
|
999
|
+
? { updateNativeDraftSnapshotConstraint: nativeDraftInput(preparedDraftSnapshot, prior) }
|
|
1000
|
+
: {}),
|
|
790
1001
|
})
|
|
791
1002
|
nativeResponse = await mapUpdateConflict(
|
|
792
1003
|
await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
|
|
@@ -1136,6 +1347,15 @@ export function createFoundryPublishRoutes(
|
|
|
1136
1347
|
if ('error' in auth) return auth.error
|
|
1137
1348
|
const row = await findArticle(ctx, id)
|
|
1138
1349
|
if (!row) return json({ error: 'not_found' }, 404)
|
|
1350
|
+
const working = new URL(ctx.request.url).searchParams.get('working')
|
|
1351
|
+
if (working !== null) {
|
|
1352
|
+
if (working !== '1' && working !== 'prepare')
|
|
1353
|
+
return json({ error: 'invalid_working_source_request' }, 400)
|
|
1354
|
+
if (!autoPublishEnabled(config))
|
|
1355
|
+
return json({ error: 'native_working_source_unavailable' }, 409)
|
|
1356
|
+
if (working === 'prepare') return draftPreparationSource(ctx, row)
|
|
1357
|
+
return workingArticleSource(ctx, row)
|
|
1358
|
+
}
|
|
1139
1359
|
console.info('[cms] foundry article read', { externalId: id, receiptId: row.receipt_id })
|
|
1140
1360
|
if (autoPublishEnabled(config)) return json(v2Receipt(row))
|
|
1141
1361
|
return json(receipt(row))
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
applyFrontsPublishCallback,
|
|
31
31
|
isFrontsPublishEnabled,
|
|
32
32
|
listFrontsPublishSchedule,
|
|
33
|
+
readFrontsPublishObservation,
|
|
33
34
|
} from '../engine/fronts-publish.js'
|
|
34
35
|
import {
|
|
35
36
|
backfillFrontsChannelTargets,
|
|
@@ -60,7 +61,10 @@ import { json, type RouteContext } from './context.js'
|
|
|
60
61
|
// family rather than inventing a second one.
|
|
61
62
|
export async function resolveFoundryHmacSecret(
|
|
62
63
|
env: Record<string, unknown> | undefined,
|
|
63
|
-
binding:
|
|
64
|
+
binding:
|
|
65
|
+
| 'FOUNDRY_HMAC_SECRET'
|
|
66
|
+
| 'CMS_FOUNDRY_HMAC_SECRET'
|
|
67
|
+
| 'INSIGHTS_INGEST_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
|
|
64
68
|
): Promise<string | null> {
|
|
65
69
|
const raw = env?.[binding]
|
|
66
70
|
if (typeof raw === 'string') return raw || null
|
|
@@ -222,6 +226,8 @@ async function runScheduleMaintenance(
|
|
|
222
226
|
}
|
|
223
227
|
|
|
224
228
|
export interface FrontsPublishRouteHandlers {
|
|
229
|
+
/** GET /fronts/publish-callback — exact authenticated receiver/commit observation. */
|
|
230
|
+
readPublishCallback(ctx: RouteContext): Promise<Response>
|
|
225
231
|
/** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
|
|
226
232
|
schedule(ctx: RouteContext): Promise<Response>
|
|
227
233
|
/** POST /fronts/schedule — bounded target arming or host-owned maintenance. */
|
|
@@ -257,6 +263,60 @@ function parseWindow(
|
|
|
257
263
|
export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
|
|
258
264
|
const resolved = resolveConfig(config)
|
|
259
265
|
return {
|
|
266
|
+
async readPublishCallback(ctx) {
|
|
267
|
+
const secret = await resolveFoundryHmacSecret(ctx.env)
|
|
268
|
+
if (!secret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
|
|
269
|
+
const url = new URL(ctx.request.url)
|
|
270
|
+
const payload = `foundry.fronts-publish-observation.v1\nGET\n${url.pathname}\n${url.search}`
|
|
271
|
+
const denied = await verifyFoundrySignedRequest(
|
|
272
|
+
ctx.request,
|
|
273
|
+
secret,
|
|
274
|
+
payload,
|
|
275
|
+
'fronts/publish-callback.read',
|
|
276
|
+
)
|
|
277
|
+
if (denied) return denied
|
|
278
|
+
if (ctx.request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405)
|
|
279
|
+
const keys = [
|
|
280
|
+
'event_id',
|
|
281
|
+
'intent_id',
|
|
282
|
+
'content_id',
|
|
283
|
+
'version',
|
|
284
|
+
'publish_at',
|
|
285
|
+
'status',
|
|
286
|
+
'receipt_sha256',
|
|
287
|
+
]
|
|
288
|
+
if (
|
|
289
|
+
[...url.searchParams.keys()].some((key) => !keys.includes(key)) ||
|
|
290
|
+
keys.some((key) => url.searchParams.getAll(key).length !== 1)
|
|
291
|
+
)
|
|
292
|
+
return json({ error: 'invalid_observation_binding' }, 400)
|
|
293
|
+
const values = Object.fromEntries(url.searchParams)
|
|
294
|
+
if (
|
|
295
|
+
['event_id', 'intent_id', 'content_id'].some(
|
|
296
|
+
(key) => !values[key] || values[key]!.length > 200,
|
|
297
|
+
) ||
|
|
298
|
+
!/^\d+$/.test(values.version!) ||
|
|
299
|
+
!Number.isSafeInteger(Number(values.version)) ||
|
|
300
|
+
Number(values.version) < 1 ||
|
|
301
|
+
!/^\d+$/.test(values.publish_at!) ||
|
|
302
|
+
!Number.isSafeInteger(Number(values.publish_at)) ||
|
|
303
|
+
!['publishing', 'live'].includes(values.status!) ||
|
|
304
|
+
(values.status === 'live'
|
|
305
|
+
? !/^[0-9a-f]{64}$/.test(values.receipt_sha256!)
|
|
306
|
+
: values.receipt_sha256 !== '')
|
|
307
|
+
)
|
|
308
|
+
return json({ error: 'invalid_observation_binding' }, 400)
|
|
309
|
+
const result = await readFrontsPublishObservation(ctx.db, {
|
|
310
|
+
eventId: values.event_id!,
|
|
311
|
+
intentId: values.intent_id!,
|
|
312
|
+
contentId: values.content_id!,
|
|
313
|
+
version: Number(values.version),
|
|
314
|
+
publishAt: Number(values.publish_at),
|
|
315
|
+
status: values.status as 'publishing' | 'live',
|
|
316
|
+
receiptSHA256: values.receipt_sha256!,
|
|
317
|
+
})
|
|
318
|
+
return json(result, result.state === 'conflict' ? 409 : 200)
|
|
319
|
+
},
|
|
260
320
|
async maintainSchedule(ctx) {
|
|
261
321
|
let responseOperation: 'arm-channel-targets' | FrontsScheduleMaintenanceOperation =
|
|
262
322
|
'arm-channel-targets'
|
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,29 @@ 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
|
+
return readyImagePublicUrl(asset ? { ...asset, asset_type: 'image', status: 'ready' } : null)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The same projection from an already captured asset row. */
|
|
35
|
+
export function readyImagePublicUrl(asset: Record<string, unknown> | null): string | null {
|
|
36
|
+
if (!asset || asset.asset_type !== 'image' || asset.status !== 'ready') return null
|
|
37
|
+
const url = z.string().url().safeParse(asset?.public_url)
|
|
38
|
+
if (!url.success) return null
|
|
39
|
+
const parsed = new URL(url.data)
|
|
40
|
+
return parsed.protocol === 'https:' && !parsed.username && !parsed.password && !parsed.hash
|
|
41
|
+
? url.data
|
|
42
|
+
: null
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
/**
|
|
22
46
|
* Resolve the public media domain, preferring `env.MEDIA_PUBLIC_DOMAIN` when it
|
|
23
47
|
* looks like a valid host, else the site-configured default. Allows
|
|
@@ -10,3 +10,6 @@ import { toContentConfig, toContentCtx } from '../_content-config.js'
|
|
|
10
10
|
|
|
11
11
|
export const POST = (c: APIContext) =>
|
|
12
12
|
createFrontsPublishRoutes(toContentConfig(c)).publishCallback(toContentCtx(c, {}))
|
|
13
|
+
|
|
14
|
+
export const GET = (c: APIContext) =>
|
|
15
|
+
createFrontsPublishRoutes(toContentConfig(c)).readPublishCallback(toContentCtx(c, {}))
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { DragHandle } from '@tiptap/extension-drag-handle'
|
|
2
|
+
import Heading from '@tiptap/extension-heading'
|
|
2
3
|
import Image from '@tiptap/extension-image'
|
|
3
4
|
import Link from '@tiptap/extension-link'
|
|
4
5
|
import { TableKit } from '@tiptap/extension-table'
|
|
5
6
|
import StarterKit from '@tiptap/starter-kit'
|
|
6
7
|
import { Callout, Citation, GeneratedFaq, PullQuote } from './blocks.js'
|
|
8
|
+
import { BODY_HEADING_LEVELS, bodyHeadingLevel } from './heading-level.js'
|
|
7
9
|
import { SlashMenu } from './slash-menu.js'
|
|
8
10
|
import { TrimBoundaryMarks } from './trim-boundary-marks.js'
|
|
9
11
|
import { TweetEmbed } from './tweet-embed.js'
|
|
10
12
|
|
|
13
|
+
/** StarterKit's heading restricted to the body levels. Every pasted h1–h6
|
|
14
|
+
* parses onto the nearest body level instead of dropping to a paragraph, so a
|
|
15
|
+
* document's Heading 1 chapter titles arrive as H2. */
|
|
16
|
+
const BodyHeading = Heading.extend({
|
|
17
|
+
parseHTML() {
|
|
18
|
+
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
|
19
|
+
tag: `h${level}`,
|
|
20
|
+
attrs: { level: bodyHeadingLevel(level) },
|
|
21
|
+
}))
|
|
22
|
+
},
|
|
23
|
+
}).configure({ levels: [...BODY_HEADING_LEVELS] })
|
|
24
|
+
|
|
11
25
|
function renderDragHandle(): HTMLElement {
|
|
12
26
|
const handle = document.createElement('div')
|
|
13
27
|
handle.className = 'drag-handle'
|
|
@@ -25,7 +39,8 @@ function renderDragHandle(): HTMLElement {
|
|
|
25
39
|
* silently drops underline styling on paste — Word documents come in clean. */
|
|
26
40
|
export function createRteExtensions() {
|
|
27
41
|
return [
|
|
28
|
-
StarterKit.configure({ link: false, underline: false }),
|
|
42
|
+
StarterKit.configure({ link: false, underline: false, heading: false }),
|
|
43
|
+
BodyHeading,
|
|
29
44
|
Link.configure({ openOnClick: false }),
|
|
30
45
|
TrimBoundaryMarks,
|
|
31
46
|
Image,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/ui/editor/heading-level.ts — PURE (no Tiptap import; serializers use it).
|
|
2
|
+
// Body heading levels. The page title is the article's only H1, so a body
|
|
3
|
+
// Heading 1 (typed as `# ` or pasted from a document) is a chapter title and
|
|
4
|
+
// becomes an H2; h5/h6 fold into H4, the deepest level the public prose styles.
|
|
5
|
+
|
|
6
|
+
export const BODY_HEADING_LEVELS = [2, 3, 4] as const
|
|
7
|
+
|
|
8
|
+
export type BodyHeadingLevel = (typeof BODY_HEADING_LEVELS)[number]
|
|
9
|
+
|
|
10
|
+
export function bodyHeadingLevel(level: number): BodyHeadingLevel {
|
|
11
|
+
if (!Number.isFinite(level) || level <= 2) return 2
|
|
12
|
+
if (level >= 4) return 4
|
|
13
|
+
return 3
|
|
14
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import type { JSONContent } from '@tiptap/core'
|
|
21
21
|
import type { PortableTextObject } from '../../schema/portable-text.js'
|
|
22
22
|
import { normalizeBoundaryMarks } from './boundary-marks.js'
|
|
23
|
+
import { bodyHeadingLevel } from './heading-level.js'
|
|
23
24
|
|
|
24
25
|
type KeyGenerator = () => string
|
|
25
26
|
|
|
@@ -78,7 +79,7 @@ function serializeBlockNode(
|
|
|
78
79
|
out.push(buildTextBlock('normal', node.content ?? [], nextKey, list))
|
|
79
80
|
return
|
|
80
81
|
case 'heading': {
|
|
81
|
-
const level = (node.attrs?.level as number) ?? 2
|
|
82
|
+
const level = bodyHeadingLevel((node.attrs?.level as number) ?? 2)
|
|
82
83
|
out.push(buildTextBlock(`h${level}`, node.content ?? [], nextKey, list))
|
|
83
84
|
return
|
|
84
85
|
}
|
|
@@ -407,7 +408,7 @@ function parseObject(block: PortableTextObject): JSONContent {
|
|
|
407
408
|
if (heading) {
|
|
408
409
|
return {
|
|
409
410
|
type: 'heading',
|
|
410
|
-
attrs: { level: Number(heading[1]) },
|
|
411
|
+
attrs: { level: bodyHeadingLevel(Number(heading[1])) },
|
|
411
412
|
content: parseInline(block),
|
|
412
413
|
}
|
|
413
414
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// no EditorView, no DOM, fully testable in vitest (Node env).
|
|
5
5
|
//
|
|
6
6
|
// Supported nodes (StarterKit set + TweetEmbed + tables):
|
|
7
|
-
// block: doc, paragraph, heading (h1
|
|
7
|
+
// block: doc, paragraph, heading (h2–h4; h1 → h2, h5/h6 → h4), bulletList, orderedList,
|
|
8
8
|
// listItem, blockquote, codeBlock, image, tweetEmbed, hardBreak,
|
|
9
9
|
// table (tableRow / tableHeader / tableCell)
|
|
10
10
|
// inline marks: bold, italic, strike, code, link
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import type { JSONContent } from '@tiptap/core'
|
|
32
32
|
import { normalizeBoundaryMarks } from './boundary-marks.js'
|
|
33
|
+
import { bodyHeadingLevel } from './heading-level.js'
|
|
33
34
|
|
|
34
35
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
36
|
// docToMarkdown — JSONContent → string
|
|
@@ -52,7 +53,7 @@ function serializeBlock(node: JSONContent): string | null {
|
|
|
52
53
|
return text
|
|
53
54
|
}
|
|
54
55
|
case 'heading': {
|
|
55
|
-
const level = (node.attrs?.level as number) ?? 2
|
|
56
|
+
const level = bodyHeadingLevel((node.attrs?.level as number) ?? 2)
|
|
56
57
|
const prefix = '#'.repeat(level)
|
|
57
58
|
const text = serializeInlineContent(node.content ?? [])
|
|
58
59
|
return `${prefix} ${text}`
|
|
@@ -389,7 +390,7 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
389
390
|
// Heading
|
|
390
391
|
const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
391
392
|
if (headingMatch) {
|
|
392
|
-
const level = headingMatch[1].length
|
|
393
|
+
const level = bodyHeadingLevel(headingMatch[1].length)
|
|
393
394
|
const text = headingMatch[2].trim()
|
|
394
395
|
nodes.push({ type: 'heading', attrs: { level }, content: parseInline(text) })
|
|
395
396
|
i++
|