@growth-labs/cms 0.8.19 → 0.8.23
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/cli/foundry-emit.d.ts +14 -0
- package/dist/cli/foundry-emit.d.ts.map +1 -0
- package/dist/cli/foundry-emit.js +69 -0
- package/dist/cli/foundry-emit.js.map +1 -0
- package/dist/engine/fronts-publish-intent.d.ts +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +12 -1
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/publication-receipt/receipt.d.ts +20 -20
- package/dist/engine/publisher.d.ts +16 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +55 -4
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +18 -1
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/topics.d.ts.map +1 -1
- package/dist/engine/topics.js +4 -1
- package/dist/engine/topics.js.map +1 -1
- package/dist/engine/video-source.d.ts +21 -0
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +40 -0
- package/dist/engine/video-source.js.map +1 -1
- package/dist/foundry-emit.d.ts +146 -0
- package/dist/foundry-emit.d.ts.map +1 -0
- package/dist/foundry-emit.js +189 -0
- package/dist/foundry-emit.js.map +1 -0
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +24 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +22 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/lib/publish-timezone.d.ts +38 -0
- package/dist/lib/publish-timezone.d.ts.map +1 -0
- package/dist/lib/publish-timezone.js +135 -0
- package/dist/lib/publish-timezone.js.map +1 -0
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +6 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +21 -13
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/analytics.d.ts +1 -0
- package/dist/routes/analytics.d.ts.map +1 -1
- package/dist/routes/analytics.js +20 -0
- package/dist/routes/analytics.js.map +1 -1
- package/dist/routes/content.d.ts +27 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +60 -114
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts +50 -0
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +540 -27
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/machine-context.d.ts +6 -0
- package/dist/routes/machine-context.d.ts.map +1 -0
- package/dist/routes/machine-context.js +20 -0
- package/dist/routes/machine-context.js.map +1 -0
- package/dist/routes/surveys.d.ts +14 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +515 -10
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +8 -8
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/surveys/access.d.ts +1 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +13 -4
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +100 -0
- package/dist/surveys/auto-label.d.ts.map +1 -0
- package/dist/surveys/auto-label.js +325 -0
- package/dist/surveys/auto-label.js.map +1 -0
- package/dist/surveys/schema.d.ts +38 -12
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +18 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +4 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/analytics.d.ts.map +1 -1
- package/dist/ui/api/analytics.js +5 -2
- package/dist/ui/api/analytics.js.map +1 -1
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
- package/dist/ui/api/surveys/[surveyId].js +28 -1
- package/dist/ui/api/surveys/[surveyId].js.map +1 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +2 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/inspector/PublishTab.d.ts +7 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +24 -26
- package/dist/ui/inspector/PublishTab.js.map +1 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +1 -0
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +13 -8
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
- package/dist/ui/screens/PodcastListeningScreen.js +244 -0
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/analytics-data.d.ts +14 -0
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -0
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
- package/dist/ui/screens/podcast-listening-data.js +98 -0
- package/dist/ui/screens/podcast-listening-data.js.map +1 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -0
- package/dist/ui/screens/registry.js.map +1 -1
- package/docs/foundry-publish.md +208 -0
- package/migrations/0033_survey_answer_labels.sql +15 -0
- package/package.json +9 -3
- package/src/cli/foundry-emit.ts +85 -0
- package/src/engine/fronts-publish-intent.ts +23 -1
- package/src/engine/publisher.ts +80 -4
- package/src/engine/tags.ts +21 -1
- package/src/engine/topics.ts +4 -1
- package/src/engine/video-source.ts +44 -0
- package/src/foundry-emit.ts +255 -0
- package/src/integration/index.ts +9 -0
- package/src/integration/options.ts +23 -0
- package/src/lib/publish-timezone.ts +154 -0
- package/src/providers/null.ts +6 -0
- package/src/providers/types.ts +52 -0
- package/src/routes/ai.ts +29 -17
- package/src/routes/analytics.ts +21 -0
- package/src/routes/content.ts +86 -131
- package/src/routes/foundry-publish.ts +746 -29
- package/src/routes/machine-context.ts +24 -0
- package/src/routes/surveys.ts +587 -12
- package/src/schema/migrations.ts +15 -0
- package/src/schema/tables.ts +2 -0
- package/src/surveys/access.ts +14 -4
- package/src/surveys/auto-label.ts +420 -0
- package/src/surveys/schema.ts +25 -1
- package/src/ui/api/_content-config.ts +6 -0
- package/src/ui/api/analytics.ts +5 -2
- package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
- package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +37 -3
- package/src/ui/commands.ts +2 -0
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/inspector/PublishTab.tsx +33 -25
- package/src/ui/nav.ts +1 -0
- package/src/ui/screens/AnalyticsScreen.tsx +34 -6
- package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
- package/src/ui/screens/analytics-data.ts +18 -0
- package/src/ui/screens/podcast-listening-data.ts +114 -0
- package/src/ui/screens/registry.tsx +2 -0
package/src/engine/publisher.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
validateDeclaredTagList,
|
|
31
31
|
validateStoredTaxonomyRows,
|
|
32
32
|
} from './taxonomy.js'
|
|
33
|
+
import { VideoSourceNotIngestableError, videoSourceUrlIngestableOverHttps } from './video-source.js'
|
|
33
34
|
|
|
34
35
|
export {
|
|
35
36
|
MAX_ITEM_TAG_COUNT,
|
|
@@ -625,6 +626,20 @@ export async function createContent(
|
|
|
625
626
|
db: D1Database,
|
|
626
627
|
input: CreateContentInput,
|
|
627
628
|
): Promise<{ id: string; slug: string }> {
|
|
629
|
+
// Refuse before the provider probe or any row write: an http_url source
|
|
630
|
+
// with a query, fragment, userinfo or non-https scheme can never pass
|
|
631
|
+
// Foundry's media probe, so storing it strands a scheduled video (the
|
|
632
|
+
// HomeFronts 2026-10-01T18:05Z incident). Dropbox links legitimately
|
|
633
|
+
// carry `?dl=1` and stay out of this check.
|
|
634
|
+
if (
|
|
635
|
+
input.type === 'video' &&
|
|
636
|
+
input.content.processingSourceUrl !== undefined &&
|
|
637
|
+
input.content.processingSourceUrl !== null &&
|
|
638
|
+
(input.content.processingSourceKind ?? 'http_url') === 'http_url' &&
|
|
639
|
+
!videoSourceUrlIngestableOverHttps(input.content.processingSourceUrl)
|
|
640
|
+
) {
|
|
641
|
+
throw new VideoSourceNotIngestableError()
|
|
642
|
+
}
|
|
628
643
|
const resolvedMediaDuration =
|
|
629
644
|
input.type === 'video'
|
|
630
645
|
? await resolveMediaDuration(
|
|
@@ -775,6 +790,21 @@ export async function getContentItem(
|
|
|
775
790
|
.first<ContentItemRecord>()
|
|
776
791
|
}
|
|
777
792
|
|
|
793
|
+
/**
|
|
794
|
+
* A human-initiated canonical change written outside updateContentItem (an
|
|
795
|
+
* editor-triggered AI write-back to a field updateContentItem does not carry):
|
|
796
|
+
* advances canonical_version exactly as updateContentItem's own UPDATE does.
|
|
797
|
+
* canonical_version is how the Foundry machine route knows the working copy is
|
|
798
|
+
* no longer only what its last PUT wrote (M3-18 resync guard).
|
|
799
|
+
*/
|
|
800
|
+
export function canonicalEditStatement(db: D1Database, contentId: string) {
|
|
801
|
+
return db
|
|
802
|
+
.prepare(
|
|
803
|
+
'UPDATE content_items SET canonical_version = canonical_version + 1, updated_at = unixepoch() WHERE id = ?',
|
|
804
|
+
)
|
|
805
|
+
.bind(contentId)
|
|
806
|
+
}
|
|
807
|
+
|
|
778
808
|
export async function updateContentItem(
|
|
779
809
|
db: D1Database,
|
|
780
810
|
id: string,
|
|
@@ -823,6 +853,16 @@ export async function updateContentItem(
|
|
|
823
853
|
const sourceKind = hasOwn(videoContent, 'processingSourceKind')
|
|
824
854
|
? (videoContent.processingSourceKind ?? null)
|
|
825
855
|
: current.processing_source_kind
|
|
856
|
+
// Same refusal as createContent, against the merged (input-or-stored)
|
|
857
|
+
// source: a row that already holds a query-bearing link is corrected
|
|
858
|
+
// at the next save, while the editor is still there to re-capture it.
|
|
859
|
+
if (
|
|
860
|
+
sourceUrl !== null &&
|
|
861
|
+
(sourceKind ?? 'http_url') === 'http_url' &&
|
|
862
|
+
!videoSourceUrlIngestableOverHttps(sourceUrl)
|
|
863
|
+
) {
|
|
864
|
+
throw new VideoSourceNotIngestableError()
|
|
865
|
+
}
|
|
826
866
|
const sourceChanged =
|
|
827
867
|
sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
|
|
828
868
|
const durationProvided = hasOwn(videoContent, 'durationSeconds')
|
|
@@ -997,8 +1037,8 @@ export async function updateContentItem(
|
|
|
997
1037
|
: existing.metadata_json
|
|
998
1038
|
|
|
999
1039
|
if (options.requiredStatus) {
|
|
1000
|
-
if (input.
|
|
1001
|
-
throw new TypeError('Status-constrained updates do not support
|
|
1040
|
+
if (input.relations !== undefined) {
|
|
1041
|
+
throw new TypeError('Status-constrained updates do not support relations')
|
|
1002
1042
|
}
|
|
1003
1043
|
if (!isBodyBackedContentType(existing.type)) {
|
|
1004
1044
|
throw new TypeError('Status-constrained updates require body-backed content')
|
|
@@ -1131,6 +1171,31 @@ export async function updateContentItem(
|
|
|
1131
1171
|
)
|
|
1132
1172
|
}
|
|
1133
1173
|
|
|
1174
|
+
if (tagPairs) {
|
|
1175
|
+
// Tag links join the same transaction under the same status predicate,
|
|
1176
|
+
// so a refused (non-matching status) update leaves the links untouched.
|
|
1177
|
+
// Tag dictionary rows are shared and idempotent, so they may be ensured
|
|
1178
|
+
// up front even when the guarded write below is refused.
|
|
1179
|
+
const tagRows = tagPairs.length > 0 ? await ensureTags(db, tagPairs) : []
|
|
1180
|
+
const statusGuard = `EXISTS (SELECT 1 FROM content_items
|
|
1181
|
+
WHERE id = ? AND status = ? AND deleted_at IS NULL)`
|
|
1182
|
+
statements.push(
|
|
1183
|
+
db
|
|
1184
|
+
.prepare(`DELETE FROM content_tag_links WHERE content_id = ? AND ${statusGuard}`)
|
|
1185
|
+
.bind(id, id, options.requiredStatus),
|
|
1186
|
+
)
|
|
1187
|
+
for (const tag of tagRows) {
|
|
1188
|
+
statements.push(
|
|
1189
|
+
db
|
|
1190
|
+
.prepare(
|
|
1191
|
+
`INSERT INTO content_tag_links (content_id, tag_id, label)
|
|
1192
|
+
SELECT ?, ?, ? WHERE ${statusGuard}`,
|
|
1193
|
+
)
|
|
1194
|
+
.bind(id, tag.id, tag.label, id, options.requiredStatus),
|
|
1195
|
+
)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1134
1199
|
const statusResultIndex = statements.length
|
|
1135
1200
|
statements.push(
|
|
1136
1201
|
db
|
|
@@ -1677,14 +1742,25 @@ export async function claimScheduledForPublish(
|
|
|
1677
1742
|
return changes > 0
|
|
1678
1743
|
}
|
|
1679
1744
|
|
|
1680
|
-
export async function unpublishContent(
|
|
1745
|
+
export async function unpublishContent(
|
|
1746
|
+
db: D1Database,
|
|
1747
|
+
id: string,
|
|
1748
|
+
options: {
|
|
1749
|
+
/**
|
|
1750
|
+
* A person pulled the item: advance canonical_version in the same UPDATE,
|
|
1751
|
+
* so a machine publisher can never put it back without a new write of its
|
|
1752
|
+
* own (the Foundry machine route's human-edit guard, M3-18).
|
|
1753
|
+
*/
|
|
1754
|
+
canonicalEdit?: boolean
|
|
1755
|
+
} = {},
|
|
1756
|
+
): Promise<void> {
|
|
1681
1757
|
await db
|
|
1682
1758
|
.prepare(
|
|
1683
1759
|
`UPDATE content_items
|
|
1684
1760
|
SET status = 'draft',
|
|
1685
1761
|
published_at = NULL,
|
|
1686
1762
|
publish_at = NULL,
|
|
1687
|
-
updated_at = unixepoch()
|
|
1763
|
+
updated_at = unixepoch()${options.canonicalEdit ? ', canonical_version = canonical_version + 1' : ''}
|
|
1688
1764
|
WHERE id = ?`,
|
|
1689
1765
|
)
|
|
1690
1766
|
.bind(id)
|
package/src/engine/tags.ts
CHANGED
|
@@ -106,12 +106,32 @@ export async function renameTag(db: D1Database, tagId: string, label: string): P
|
|
|
106
106
|
.prepare('UPDATE content_tags SET label = ?, slug = ? WHERE id = ?')
|
|
107
107
|
.bind(tag.label, tag.slug, tagId),
|
|
108
108
|
db.prepare('UPDATE content_tag_links SET label = ? WHERE tag_id = ?').bind(tag.label, tagId),
|
|
109
|
+
// A rename is a human-initiated canonical change to every item carrying
|
|
110
|
+
// the tag, exactly as topic rename is (engine/topics.ts).
|
|
111
|
+
bumpTaggedItems(db, tagId),
|
|
109
112
|
])
|
|
110
113
|
return Boolean(res.meta && (res.meta as { changes?: number }).changes)
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
export async function deleteTag(db: D1Database, tagId: string): Promise<boolean> {
|
|
114
117
|
// content_tag_links has ON DELETE CASCADE on tag_id (0001), so links go too.
|
|
115
|
-
|
|
118
|
+
// The tagged items are bumped first, in the same batch, while the links that
|
|
119
|
+
// name them still exist.
|
|
120
|
+
const [, res] = await db.batch([
|
|
121
|
+
bumpTaggedItems(db, tagId),
|
|
122
|
+
db.prepare('DELETE FROM content_tags WHERE id = ?').bind(tagId),
|
|
123
|
+
])
|
|
116
124
|
return Boolean(res.meta && (res.meta as { changes?: number }).changes)
|
|
117
125
|
}
|
|
126
|
+
|
|
127
|
+
/** canonical_version + 1 for every live item linked to the tag. */
|
|
128
|
+
function bumpTaggedItems(db: D1Database, tagId: string) {
|
|
129
|
+
return db
|
|
130
|
+
.prepare(
|
|
131
|
+
`UPDATE content_items
|
|
132
|
+
SET canonical_version = canonical_version + 1, updated_at = unixepoch()
|
|
133
|
+
WHERE deleted_at IS NULL
|
|
134
|
+
AND id IN (SELECT content_id FROM content_tag_links WHERE tag_id = ?)`,
|
|
135
|
+
)
|
|
136
|
+
.bind(tagId)
|
|
137
|
+
}
|
package/src/engine/topics.ts
CHANGED
|
@@ -133,7 +133,10 @@ export async function renameTopic(
|
|
|
133
133
|
const res = await db
|
|
134
134
|
.prepare(
|
|
135
135
|
`UPDATE content_items
|
|
136
|
-
SET primary_topic = ?, updated_at = unixepoch()
|
|
136
|
+
SET primary_topic = ?, updated_at = unixepoch(),
|
|
137
|
+
-- A rename is a human-initiated canonical change to every item it
|
|
138
|
+
-- touches, exactly as an editor's per-item topic change is.
|
|
139
|
+
canonical_version = canonical_version + 1
|
|
137
140
|
WHERE deleted_at IS NULL AND primary_topic = ?`,
|
|
138
141
|
)
|
|
139
142
|
.bind(nextSlug, currentSlug)
|
|
@@ -46,3 +46,47 @@ export async function frontsVideoSourceGeneration(
|
|
|
46
46
|
export function videoSourceIngestableOverHttps(kind: string | null | undefined): boolean {
|
|
47
47
|
return kind === 'http_url' || kind === 'dropbox'
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The URL shape Foundry's ingest accepts (capabilityplane/ingest.go
|
|
52
|
+
* `validHTTPSIngestURL`): https, a host, and no userinfo, query or
|
|
53
|
+
* fragment. A URL outside this shape is refused at the Go media probe with
|
|
54
|
+
* "artifact ingest probe source URL is invalid" — the failure that blocked
|
|
55
|
+
* a scheduled HomeFronts video on 2026-10-01T18:05Z. The IP-range checks
|
|
56
|
+
* (`PublicHTTPSIngestHost`) stay Foundry's: the CMS only refuses what no
|
|
57
|
+
* host would accept.
|
|
58
|
+
*/
|
|
59
|
+
export function videoSourceUrlIngestableOverHttps(rawUrl: string | null | undefined): boolean {
|
|
60
|
+
if (!rawUrl) return false
|
|
61
|
+
let url: URL
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(rawUrl)
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
url.protocol === 'https:' &&
|
|
69
|
+
url.hostname !== '' &&
|
|
70
|
+
!url.username &&
|
|
71
|
+
!url.password &&
|
|
72
|
+
url.search === '' &&
|
|
73
|
+
url.hash === ''
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A video source whose URL `videoSourceUrlIngestableOverHttps` refuses (a
|
|
79
|
+
* query string, fragment, userinfo or non-https scheme). Thrown by the
|
|
80
|
+
* publisher's video write so the save fails before a row can strand a
|
|
81
|
+
* scheduled video at the Go media probe. The message is the whole
|
|
82
|
+
* editor-facing error and deliberately echoes no part of the URL — a share
|
|
83
|
+
* link's path or query can carry its token.
|
|
84
|
+
*/
|
|
85
|
+
export class VideoSourceNotIngestableError extends Error {
|
|
86
|
+
constructor() {
|
|
87
|
+
super(
|
|
88
|
+
'Video source URL must be a direct HTTPS link with no query string or fragment — Foundry refuses to ingest it. A Fulcrum Drive share link must be pasted in its direct /download form.',
|
|
89
|
+
)
|
|
90
|
+
this.name = 'VideoSourceNotIngestableError'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// @growth-labs/cms/foundry-emit — the one v2 schema for the Foundry machine
|
|
2
|
+
// article route, shared by the route (routes/foundry-publish.ts) and by every
|
|
3
|
+
// producer that emits into it, plus a pure mapper from the archive-import item
|
|
4
|
+
// shape (ContentArchiveItem, as the channel repos' compiler.mjs builds it) to a
|
|
5
|
+
// v2 body.
|
|
6
|
+
//
|
|
7
|
+
// This module must stay importable in pure Node: zod, the pure slug helpers and
|
|
8
|
+
// type-only imports. Nothing here touches D1, the network or `cloudflare:*`.
|
|
9
|
+
|
|
10
|
+
import { sha256Hex } from '@growth-labs/foundry-signing'
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
import type { ContentArchiveItem } from './engine/publication.js'
|
|
13
|
+
import { slugify } from './engine/slug.js'
|
|
14
|
+
|
|
15
|
+
export const FOUNDRY_ARTICLE_SCHEMA_VERSION = 2 as const
|
|
16
|
+
|
|
17
|
+
/** The machine route's own source namespace; an adoption never targets it. */
|
|
18
|
+
export const FOUNDRY_SOURCE_PREFIX = 'foundry:'
|
|
19
|
+
|
|
20
|
+
/** Upper bound the publication kernel accepts for a revision's slug. */
|
|
21
|
+
export const FOUNDRY_SLUG_MAX_LENGTH = 512
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The engine's slug rule, applied verbatim: a v2 slug is accepted only when it
|
|
25
|
+
* is already canonical (`slugify(slug) === slug`), because the route never
|
|
26
|
+
* derives, rewrites or suffixes a v2 slug.
|
|
27
|
+
*/
|
|
28
|
+
export function isCanonicalSlug(value: string): boolean {
|
|
29
|
+
return value.length >= 3 && value.length <= FOUNDRY_SLUG_MAX_LENGTH && slugify(value) === value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HERO_IMAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9._\-/]*$/
|
|
33
|
+
|
|
34
|
+
export const FoundryArticleV2Schema = z
|
|
35
|
+
.object({
|
|
36
|
+
schemaVersion: z.literal(FOUNDRY_ARTICLE_SCHEMA_VERSION),
|
|
37
|
+
slug: z.string().refine(isCanonicalSlug, {
|
|
38
|
+
message: 'slug must already be canonical: lowercase a-z, 0-9 and single hyphens, 3-512 chars',
|
|
39
|
+
}),
|
|
40
|
+
title: z.string().trim().min(3),
|
|
41
|
+
body_html: z.string().min(1),
|
|
42
|
+
hero_image_key: z
|
|
43
|
+
.string()
|
|
44
|
+
.min(1)
|
|
45
|
+
.max(1024)
|
|
46
|
+
.refine((key) => HERO_IMAGE_KEY.test(key) && !key.split('/').includes('..'), {
|
|
47
|
+
message: 'hero_image_key must be a relative object key',
|
|
48
|
+
})
|
|
49
|
+
.optional(),
|
|
50
|
+
hero_image_url: z
|
|
51
|
+
.string()
|
|
52
|
+
.url()
|
|
53
|
+
.refine((url) => url.startsWith('https://'), { message: 'hero_image_url must be https' })
|
|
54
|
+
.optional(),
|
|
55
|
+
publishAt: z.string().datetime({ offset: true }).optional(),
|
|
56
|
+
author: z.string().trim().min(1).max(200).optional(),
|
|
57
|
+
desk: z.string().trim().min(1).max(100).optional(),
|
|
58
|
+
tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
|
59
|
+
description: z.string().trim().min(1).optional(),
|
|
60
|
+
visibility: z.enum(['free', 'premium']).optional(),
|
|
61
|
+
adoptSourceId: z
|
|
62
|
+
.string()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(256)
|
|
65
|
+
.refine((value) => value === value.trim() && !value.startsWith(FOUNDRY_SOURCE_PREFIX), {
|
|
66
|
+
message: 'adoptSourceId must be a trimmed bridge source id, never a foundry: id',
|
|
67
|
+
})
|
|
68
|
+
.optional(),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
.refine((body) => !(body.hero_image_key && body.hero_image_url), {
|
|
72
|
+
message: 'send hero_image_key or hero_image_url, not both',
|
|
73
|
+
path: ['hero_image_key'],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export type FoundryArticleV2 = z.output<typeof FoundryArticleV2Schema>
|
|
77
|
+
|
|
78
|
+
export class FoundryEmitError extends Error {
|
|
79
|
+
readonly code: string
|
|
80
|
+
|
|
81
|
+
constructor(code: string, message: string) {
|
|
82
|
+
super(message)
|
|
83
|
+
this.name = 'FoundryEmitError'
|
|
84
|
+
this.code = code
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FoundryEmitOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Overrides the publication time. Omitted: the item's `publishedAt` (unix
|
|
91
|
+
* seconds) is used when present. `null`: no publishAt, so the route writes a
|
|
92
|
+
* draft only.
|
|
93
|
+
*/
|
|
94
|
+
publishAt?: string | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function present(value: string | null | undefined): value is string {
|
|
98
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Map one archive-import item to a v2 route body. Pure: the same item always
|
|
103
|
+
* yields the same body, and the result is parsed by the route's own schema, so
|
|
104
|
+
* a body this function returns is one the route accepts.
|
|
105
|
+
*/
|
|
106
|
+
export function toFoundryArticleV2(
|
|
107
|
+
item: ContentArchiveItem,
|
|
108
|
+
options: FoundryEmitOptions = {},
|
|
109
|
+
): FoundryArticleV2 {
|
|
110
|
+
if (item.type !== undefined && item.type !== 'article') {
|
|
111
|
+
throw new FoundryEmitError('unsupported_type', `only articles are emitted, got ${item.type}`)
|
|
112
|
+
}
|
|
113
|
+
if (!present(item.slug)) throw new FoundryEmitError('slug_missing', 'archive item has no slug')
|
|
114
|
+
if (!present(item.title)) throw new FoundryEmitError('title_missing', 'archive item has no title')
|
|
115
|
+
if (!present(item.bodyHtml)) {
|
|
116
|
+
throw new FoundryEmitError('body_html_missing', 'archive item has no rendered bodyHtml')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let publishAt: string | undefined
|
|
120
|
+
if (options.publishAt !== undefined) {
|
|
121
|
+
publishAt = options.publishAt ?? undefined
|
|
122
|
+
} else if (typeof item.publishedAt === 'number' && Number.isFinite(item.publishedAt)) {
|
|
123
|
+
publishAt = new Date(item.publishedAt * 1_000).toISOString()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hero = present(item.heroImageId) ? item.heroImageId.trim() : null
|
|
127
|
+
const author = present(item.byline) ? item.byline : present(item.authorId) ? item.authorId : null
|
|
128
|
+
const description = present(item.description)
|
|
129
|
+
? item.description
|
|
130
|
+
: present(item.excerpt)
|
|
131
|
+
? item.excerpt
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
const body: Record<string, unknown> = {
|
|
135
|
+
schemaVersion: FOUNDRY_ARTICLE_SCHEMA_VERSION,
|
|
136
|
+
slug: item.slug,
|
|
137
|
+
title: item.title,
|
|
138
|
+
body_html: item.bodyHtml,
|
|
139
|
+
...(hero
|
|
140
|
+
? /^https?:\/\//i.test(hero)
|
|
141
|
+
? { hero_image_url: hero }
|
|
142
|
+
: { hero_image_key: hero }
|
|
143
|
+
: {}),
|
|
144
|
+
...(publishAt ? { publishAt } : {}),
|
|
145
|
+
...(author ? { author } : {}),
|
|
146
|
+
...(present(item.primaryCategory) ? { desk: item.primaryCategory } : {}),
|
|
147
|
+
...(item.tags && item.tags.length > 0 ? { tags: [...item.tags] } : {}),
|
|
148
|
+
...(description ? { description } : {}),
|
|
149
|
+
...(item.visibility ? { visibility: item.visibility } : {}),
|
|
150
|
+
...(present(item.sourceId) ? { adoptSourceId: item.sourceId } : {}),
|
|
151
|
+
}
|
|
152
|
+
return FoundryArticleV2Schema.parse(body)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- content-emit.json: the whole archive as the exact PUT bodies foundryd sends ---
|
|
156
|
+
|
|
157
|
+
export const CONTENT_EMIT_SCHEMA_VERSION = 1 as const
|
|
158
|
+
|
|
159
|
+
/** An item the producer retires (frontmatter `status: archived`): no request is sent. */
|
|
160
|
+
export interface ContentEmitRetireItem {
|
|
161
|
+
path: string
|
|
162
|
+
videoId: string | null
|
|
163
|
+
retire: true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* An item to PUT. `request` is the exact PUT body as a JSON STRING, never a
|
|
168
|
+
* nested object, so re-indenting the envelope cannot change its bytes;
|
|
169
|
+
* `requestSha256` is the lowercase hex SHA-256 of its UTF-8 bytes, which is the
|
|
170
|
+
* `request_sha256` the route stores and echoes for that PUT.
|
|
171
|
+
*/
|
|
172
|
+
export interface ContentEmitPutItem {
|
|
173
|
+
path: string
|
|
174
|
+
videoId: string | null
|
|
175
|
+
retire: false
|
|
176
|
+
requestSha256: string
|
|
177
|
+
request: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type ContentEmitItem = ContentEmitRetireItem | ContentEmitPutItem
|
|
181
|
+
|
|
182
|
+
export interface ContentEmit {
|
|
183
|
+
schemaVersion: typeof CONTENT_EMIT_SCHEMA_VERSION
|
|
184
|
+
app: string
|
|
185
|
+
sourceSha: string
|
|
186
|
+
items: ContentEmitItem[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const APP_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
190
|
+
const SOURCE_SHA = /^[0-9a-f]{7,64}$/
|
|
191
|
+
|
|
192
|
+
/** Lowercase hex SHA-256 of a string's UTF-8 bytes (the shared implementation). */
|
|
193
|
+
export { sha256Hex }
|
|
194
|
+
|
|
195
|
+
function archivePath(item: ContentArchiveItem, index: number): string {
|
|
196
|
+
const sourceId = item.sourceId
|
|
197
|
+
const separator = typeof sourceId === 'string' ? sourceId.indexOf(':') : -1
|
|
198
|
+
if (!sourceId || separator < 1 || separator === sourceId.length - 1) {
|
|
199
|
+
throw new FoundryEmitError(
|
|
200
|
+
'source_id_missing',
|
|
201
|
+
`archive item ${index} has no "<ns>:<relpath>" sourceId`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return sourceId.slice(separator + 1)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build content-emit.json for a WHOLE archive. Every item is either retired
|
|
209
|
+
* (`metadata.originalStatus === 'archived'`, the compiler's carry of frontmatter
|
|
210
|
+
* `status: archived`) or carries its exact v2 PUT body. Any item that cannot be
|
|
211
|
+
* emitted fails the whole build: a partial emit would silently drop articles.
|
|
212
|
+
*/
|
|
213
|
+
export async function buildContentEmit(
|
|
214
|
+
archive: { items?: ContentArchiveItem[] },
|
|
215
|
+
options: { app: string; sourceSha: string },
|
|
216
|
+
): Promise<ContentEmit> {
|
|
217
|
+
if (!APP_ID.test(options.app)) {
|
|
218
|
+
throw new FoundryEmitError('app_invalid', 'app must be a lowercase id (a-z, 0-9, -)')
|
|
219
|
+
}
|
|
220
|
+
if (!SOURCE_SHA.test(options.sourceSha)) {
|
|
221
|
+
throw new FoundryEmitError('source_sha_invalid', 'sourceSha must be a lowercase hex commit')
|
|
222
|
+
}
|
|
223
|
+
if (!Array.isArray(archive.items)) {
|
|
224
|
+
throw new FoundryEmitError('archive_invalid', 'archive has no items array')
|
|
225
|
+
}
|
|
226
|
+
const seen = new Set<string>()
|
|
227
|
+
const items: ContentEmitItem[] = []
|
|
228
|
+
for (const [index, item] of archive.items.entries()) {
|
|
229
|
+
const path = archivePath(item, index)
|
|
230
|
+
if (seen.has(path)) throw new FoundryEmitError('path_duplicate', `duplicate path ${path}`)
|
|
231
|
+
seen.add(path)
|
|
232
|
+
const videoId =
|
|
233
|
+
typeof item.metadata?.videoId === 'string' && item.metadata.videoId
|
|
234
|
+
? item.metadata.videoId
|
|
235
|
+
: null
|
|
236
|
+
if (item.metadata?.originalStatus === 'archived') {
|
|
237
|
+
items.push({ path, videoId, retire: true })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
let request: string
|
|
241
|
+
try {
|
|
242
|
+
request = JSON.stringify(toFoundryArticleV2(item))
|
|
243
|
+
} catch (error) {
|
|
244
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
245
|
+
throw new FoundryEmitError('item_invalid', `${path}: ${detail}`)
|
|
246
|
+
}
|
|
247
|
+
items.push({ path, videoId, retire: false, requestSha256: await sha256Hex(request), request })
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
schemaVersion: CONTENT_EMIT_SCHEMA_VERSION,
|
|
251
|
+
app: options.app,
|
|
252
|
+
sourceSha: options.sourceSha,
|
|
253
|
+
items,
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/integration/index.ts
CHANGED
|
@@ -33,6 +33,10 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
33
33
|
pattern: '/api/cms/foundry/articles/[external_id]/unpublish',
|
|
34
34
|
entrypoint: resolveEntrypoint('../ui/api/foundry/articles/[external_id]/unpublish'),
|
|
35
35
|
})
|
|
36
|
+
injectRoute({
|
|
37
|
+
pattern: '/api/cms/foundry/articles/[external_id]/publish',
|
|
38
|
+
entrypoint: resolveEntrypoint('../ui/api/foundry/articles/[external_id]/publish'),
|
|
39
|
+
})
|
|
36
40
|
// the SPA shell index (SSR, prerender=false)
|
|
37
41
|
injectRoute({ pattern: base, entrypoint: resolveAstro('../ui/pages/admin.astro') })
|
|
38
42
|
// the /admin/api/* JSON routes
|
|
@@ -270,6 +274,11 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
270
274
|
pattern: `${base}/api/surveys/[surveyId]/responses.csv`,
|
|
271
275
|
entrypoint: resolveEntrypoint('../ui/api/surveys/[surveyId]/responses.csv'),
|
|
272
276
|
})
|
|
277
|
+
// Owner-only classification of a written answer (technical/general).
|
|
278
|
+
injectRoute({
|
|
279
|
+
pattern: `${base}/api/surveys/[surveyId]/answers/label`,
|
|
280
|
+
entrypoint: resolveEntrypoint('../ui/api/surveys/[surveyId]/answers/label'),
|
|
281
|
+
})
|
|
273
282
|
// Calendar
|
|
274
283
|
injectRoute({
|
|
275
284
|
pattern: `${base}/api/calendar`,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isCanonicalHost } from '@growth-labs/foundry-signing'
|
|
1
2
|
import { z } from 'zod'
|
|
2
3
|
import { SurveyIdSchema } from '../surveys/schema.js'
|
|
3
4
|
|
|
@@ -124,6 +125,17 @@ const surveyResultsSchema = z
|
|
|
124
125
|
})
|
|
125
126
|
.strict()
|
|
126
127
|
|
|
128
|
+
const surveysOptionsSchema = z
|
|
129
|
+
.object({
|
|
130
|
+
// Automatic technical/general labeling of new written survey answers
|
|
131
|
+
// through the host's LlmDispatch (2026-10-02, packet 08), scheduled off
|
|
132
|
+
// the survey results request via waitUntil. Off by default: a site
|
|
133
|
+
// opts in, and only a site that wired the LlmDispatch seam pays for
|
|
134
|
+
// the background dispatches.
|
|
135
|
+
autoLabel: z.boolean().default(false),
|
|
136
|
+
})
|
|
137
|
+
.default({})
|
|
138
|
+
|
|
127
139
|
export const cmsIntegrationOptionsSchema = z
|
|
128
140
|
.object({
|
|
129
141
|
workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
|
|
@@ -143,6 +155,17 @@ export const cmsIntegrationOptionsSchema = z
|
|
|
143
155
|
theme: themeSchema,
|
|
144
156
|
socialSharing: socialSharingSchema,
|
|
145
157
|
surveyResults: surveyResultsSchema.optional(),
|
|
158
|
+
surveys: surveysOptionsSchema,
|
|
159
|
+
// This publication's canonical host for the Foundry machine route
|
|
160
|
+
// (/api/cms/foundry/*), e.g. `warfronts.pub`. Declared, the route verifies
|
|
161
|
+
// only the host-bound foundry-cms-v2 signature on every request and never
|
|
162
|
+
// the legacy form, whatever the per-request hooks set.
|
|
163
|
+
foundryCanonicalHost: z
|
|
164
|
+
.string()
|
|
165
|
+
.refine(isCanonicalHost, {
|
|
166
|
+
message: 'foundryCanonicalHost must be a lowercase host with no scheme or port',
|
|
167
|
+
})
|
|
168
|
+
.optional(),
|
|
146
169
|
})
|
|
147
170
|
.transform((o) => ({ ...o, activeWorkspaceId: o.activeWorkspaceId ?? o.workspaces[0].id }))
|
|
148
171
|
|