@growth-labs/cms 0.8.20 → 0.8.24
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/foundry-dispatch.d.ts +1 -0
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +1 -1
- package/dist/engine/foundry-dispatch.js.map +1 -1
- 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/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/index.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/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 +58 -12
- 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/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/foundry-dispatch.ts +1 -1
- package/src/engine/fronts-publish-intent.ts +23 -1
- package/src/engine/index.ts +5 -0
- 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/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 +81 -15
- 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/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
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/foundry-signing'
|
|
1
2
|
import { z } from 'zod'
|
|
2
|
-
import {
|
|
3
|
+
import { type PublicationReconciler, publishOne } from '../engine/publication.js'
|
|
4
|
+
import { PublishContentValidationError } from '../engine/publisher.js'
|
|
5
|
+
import { createRevisionWithDelta } from '../engine/revisions.js'
|
|
3
6
|
import { buildContentPath, slugify } from '../engine/slug.js'
|
|
7
|
+
import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.js'
|
|
4
8
|
import { type ContentRouteConfig, createContentRoutes } from './content.js'
|
|
5
9
|
import { json, type RouteContext } from './context.js'
|
|
10
|
+
import { markFoundryMachineContext } from './machine-context.js'
|
|
6
11
|
import { insertMediaAsset } from './media-lib.js'
|
|
7
12
|
|
|
8
|
-
const MAX_SKEW_SECONDS = 300
|
|
9
13
|
const SOURCE_PREFIX = 'foundry:'
|
|
10
14
|
|
|
11
15
|
const PutSchema = z
|
|
@@ -26,10 +30,60 @@ interface FoundryArticleRow {
|
|
|
26
30
|
slug: string
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The per-publication opt-in for the publish transition (M3-18). It lives on the
|
|
35
|
+
* route's config (`ContentRouteConfig.foundryPublish`); the Astro adapter reads it
|
|
36
|
+
* from `locals.cmsHooks.foundryPublish`. Only `autoPublish === true` turns it on.
|
|
37
|
+
*/
|
|
38
|
+
export interface FoundryPublishOptions {
|
|
39
|
+
/** `true` enables v2 PUTs (drafts, in-place edits of published items) and POST /publish. */
|
|
40
|
+
autoPublish?: boolean
|
|
41
|
+
/**
|
|
42
|
+
* The publication's LIVE surfaces (sitemap, feed, .md twin, ...), resolved per
|
|
43
|
+
* request and handed to `publishOne`. The route never passes
|
|
44
|
+
* `allowNoSurfaces`: an absent or empty list refuses the publish with 503
|
|
45
|
+
* before anything is written.
|
|
46
|
+
*/
|
|
47
|
+
surfaces?: (ctx: RouteContext) => PublicationReconciler[] | Promise<PublicationReconciler[]>
|
|
48
|
+
/** Forwarded to `publishOne`; each named surface must be present. */
|
|
49
|
+
requiredSurfaceNames?: string[]
|
|
50
|
+
/** Forwarded to `publishOne` for its ledger. */
|
|
51
|
+
packageVersion?: string | null
|
|
52
|
+
/**
|
|
53
|
+
* Declares that the publication's scheduler publishes scheduled items through
|
|
54
|
+
* `publishOne` with these live surfaces. Without it, POST /publish refuses a
|
|
55
|
+
* future publishAt with `409 scheduling_requires_surface_scheduler`, because
|
|
56
|
+
* the legacy scheduled sweep (engine/cron.ts runScheduledPublish) publishes
|
|
57
|
+
* with no surfaces.
|
|
58
|
+
*/
|
|
59
|
+
scheduling?: 'publishOne'
|
|
60
|
+
/**
|
|
61
|
+
* The namespace this publication's archive bridge writes into
|
|
62
|
+
* `content_items.source_id` (`<namespace>:<relpath>`, e.g. `warfronts`; see a
|
|
63
|
+
* channel repo's scripts/archive-import/compiler.mjs SOURCE_NAMESPACE and
|
|
64
|
+
* buildSourceId). Only items under exactly this prefix can be adopted; with
|
|
65
|
+
* none declared, nothing can.
|
|
66
|
+
*/
|
|
67
|
+
bridgeSourceNamespace?: string
|
|
68
|
+
/**
|
|
69
|
+
* REQUIRED when `autoPublish` is on: this publication's canonical host,
|
|
70
|
+
* lowercase with no scheme and no port (e.g. `warfronts.pub`). The Foundry
|
|
71
|
+
* HMAC secret is shared by every site, so a publication that declares a host
|
|
72
|
+
* (here or as `ContentRouteConfig.foundryCanonicalHost`, with or without
|
|
73
|
+
* `autoPublish`) verifies the host-bound `foundry-cms-v2` string with THIS
|
|
74
|
+
* host (never the request's Host header) on every /api/cms/foundry/* verb,
|
|
75
|
+
* and refuses the legacy dot form and a request signed for another site.
|
|
76
|
+
* Malformed, or disagreeing with `foundryCanonicalHost`: every verb answers 503.
|
|
77
|
+
*/
|
|
78
|
+
canonicalHost?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
29
81
|
export interface FoundryPublishRouteHandlers {
|
|
30
82
|
put(ctx: RouteContext): Promise<Response>
|
|
31
83
|
get(ctx: RouteContext): Promise<Response>
|
|
32
84
|
unpublish(ctx: RouteContext): Promise<Response>
|
|
85
|
+
/** M3-18: publish (or resync) through publishOne; 409 unless the publication opted in. */
|
|
86
|
+
publish(ctx: RouteContext): Promise<Response>
|
|
33
87
|
}
|
|
34
88
|
|
|
35
89
|
async function resolveSecret(env: Record<string, unknown> | undefined): Promise<string | null> {
|
|
@@ -46,36 +100,32 @@ async function resolveSecret(env: Record<string, unknown> | undefined): Promise<
|
|
|
46
100
|
return null
|
|
47
101
|
}
|
|
48
102
|
|
|
49
|
-
async function sha256(value: string): Promise<string> {
|
|
50
|
-
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
|
|
51
|
-
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
103
|
async function authenticate(
|
|
55
104
|
ctx: RouteContext,
|
|
56
105
|
body: string,
|
|
106
|
+
canonicalHost: string | null = null,
|
|
57
107
|
): Promise<{ bodyHash: string } | { error: Response }> {
|
|
58
108
|
const secret = await resolveSecret(ctx.env)
|
|
59
109
|
if (!secret) return { error: json({ error: 'GL_CMS_FOUNDRY_UNCONFIGURED' }, 503) }
|
|
60
110
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
!Number.isSafeInteger(seconds) ||
|
|
66
|
-
Math.abs(Math.floor(Date.now() / 1_000) - seconds) > MAX_SKEW_SECONDS
|
|
67
|
-
) {
|
|
68
|
-
return { error: json({ error: 'unauthorized' }, 401) }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const bodyHash = await sha256(body)
|
|
111
|
+
// The one Foundry route-family verifier (@growth-labs/foundry-signing): the
|
|
112
|
+
// host-bound foundry-cms-v2 string with this publication's CONFIGURED
|
|
113
|
+
// canonical host when it opted in (M3-18), else the legacy dot string,
|
|
114
|
+
// byte-for-byte as before.
|
|
72
115
|
const path = new URL(ctx.request.url).pathname
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
116
|
+
const verified = await verifyFoundryRequest(secret, {
|
|
117
|
+
method: ctx.request.method,
|
|
118
|
+
host: canonicalHost,
|
|
119
|
+
path,
|
|
120
|
+
timestampHeader: ctx.request.headers.get('X-Foundry-Timestamp'),
|
|
121
|
+
signatureHeader: ctx.request.headers.get('X-Foundry-Signature'),
|
|
122
|
+
body,
|
|
123
|
+
})
|
|
124
|
+
if (!verified) {
|
|
125
|
+
console.warn('[cms] foundry article request rejected', { path })
|
|
77
126
|
return { error: json({ error: 'unauthorized' }, 401) }
|
|
78
127
|
}
|
|
128
|
+
const bodyHash = await sha256Hex(body)
|
|
79
129
|
return { bodyHash }
|
|
80
130
|
}
|
|
81
131
|
|
|
@@ -140,7 +190,9 @@ function nativeContext(
|
|
|
140
190
|
params: Record<string, string>,
|
|
141
191
|
query = '',
|
|
142
192
|
): RouteContext {
|
|
143
|
-
|
|
193
|
+
// Every caller has already verified the Foundry HMAC: the native context it
|
|
194
|
+
// builds is the machine's, and is marked as such (see machine-context.ts).
|
|
195
|
+
return markFoundryMachineContext({
|
|
144
196
|
...ctx,
|
|
145
197
|
request: new Request(`${new URL(ctx.request.url).origin}/internal/cms${query}`, {
|
|
146
198
|
method,
|
|
@@ -149,7 +201,7 @@ function nativeContext(
|
|
|
149
201
|
}),
|
|
150
202
|
params,
|
|
151
203
|
userId: 'foundry',
|
|
152
|
-
}
|
|
204
|
+
})
|
|
153
205
|
}
|
|
154
206
|
|
|
155
207
|
async function upsertReceipt(
|
|
@@ -172,7 +224,7 @@ async function upsertReceipt(
|
|
|
172
224
|
}
|
|
173
225
|
|
|
174
226
|
async function heroAssetId(url: string): Promise<string> {
|
|
175
|
-
return `foundry-hero-${(await
|
|
227
|
+
return `foundry-hero-${(await sha256Hex(url)).slice(0, 24)}`
|
|
176
228
|
}
|
|
177
229
|
|
|
178
230
|
async function persistHeroAsset(
|
|
@@ -215,17 +267,654 @@ async function mapUpdateConflict(response: Response, id: string): Promise<Respon
|
|
|
215
267
|
return response
|
|
216
268
|
}
|
|
217
269
|
|
|
270
|
+
// --- v2 (M3-18): opt-in publish transition, verbatim slug, one-time adoption ---
|
|
271
|
+
//
|
|
272
|
+
// Everything below runs only when `config.foundryPublish.autoPublish === true`
|
|
273
|
+
// AND the body declares `schemaVersion: 2`. Every other request takes the v1
|
|
274
|
+
// path above, unchanged.
|
|
275
|
+
|
|
276
|
+
type ContentHandlers = ReturnType<typeof createContentRoutes>
|
|
277
|
+
|
|
278
|
+
function autoPublishEnabled(config: ContentRouteConfig): boolean {
|
|
279
|
+
return config.foundryPublish?.autoPublish === true
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The canonical host this publication declared for the Foundry route family:
|
|
284
|
+
* the static `foundryCanonicalHost` and/or the opt-in's
|
|
285
|
+
* `foundryPublish.canonicalHost`. `null` when neither is declared. A declared
|
|
286
|
+
* host is never downgraded to the legacy form: a malformed host, or two that
|
|
287
|
+
* disagree, resolve to `{ host: null }`, which fails closed.
|
|
288
|
+
*/
|
|
289
|
+
function declaredCanonicalHost(config: ContentRouteConfig): { host: string | null } | null {
|
|
290
|
+
const declared = [config.foundryCanonicalHost, config.foundryPublish?.canonicalHost].filter(
|
|
291
|
+
(value) => value !== undefined,
|
|
292
|
+
)
|
|
293
|
+
if (declared.length === 0) return null
|
|
294
|
+
const [first] = declared
|
|
295
|
+
const agreed = declared.every(
|
|
296
|
+
(value) => typeof value === 'string' && isCanonicalHost(value) && value === first,
|
|
297
|
+
)
|
|
298
|
+
return { host: agreed ? (first as string) : null }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isV2Payload(payload: unknown): boolean {
|
|
302
|
+
return (
|
|
303
|
+
typeof payload === 'object' &&
|
|
304
|
+
payload !== null &&
|
|
305
|
+
!Array.isArray(payload) &&
|
|
306
|
+
(payload as { schemaVersion?: unknown }).schemaVersion === 2
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function resolveLiveSurfaces(
|
|
311
|
+
ctx: RouteContext,
|
|
312
|
+
config: ContentRouteConfig,
|
|
313
|
+
): Promise<PublicationReconciler[] | null> {
|
|
314
|
+
const resolve = config.foundryPublish?.surfaces
|
|
315
|
+
if (typeof resolve !== 'function') return null
|
|
316
|
+
try {
|
|
317
|
+
const surfaces = await resolve(ctx)
|
|
318
|
+
return Array.isArray(surfaces) && surfaces.length > 0 ? surfaces : null
|
|
319
|
+
} catch (error) {
|
|
320
|
+
console.error('[cms] foundry publish surfaces could not be resolved', error)
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface ContentCandidate {
|
|
326
|
+
id: string
|
|
327
|
+
status: FoundryArticleRow['status']
|
|
328
|
+
type: string
|
|
329
|
+
slug: string
|
|
330
|
+
source_id: string | null
|
|
331
|
+
deleted_at: number | null
|
|
332
|
+
bound_external_id: string | null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
interface Adoption {
|
|
336
|
+
content_id: string
|
|
337
|
+
from_source_id: string
|
|
338
|
+
matched_by: 'source_id' | 'slug'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function findCandidate(
|
|
342
|
+
ctx: RouteContext,
|
|
343
|
+
column: 'source_id' | 'slug',
|
|
344
|
+
value: string,
|
|
345
|
+
): Promise<ContentCandidate | null> {
|
|
346
|
+
return ctx.db
|
|
347
|
+
.prepare(
|
|
348
|
+
`SELECT c.id, c.status, c.type, c.slug, c.source_id, c.deleted_at,
|
|
349
|
+
f.external_id AS bound_external_id
|
|
350
|
+
FROM content_items c
|
|
351
|
+
LEFT JOIN cms_foundry_articles f ON f.content_id = c.id
|
|
352
|
+
WHERE c.${column} = ?
|
|
353
|
+
LIMIT 1`,
|
|
354
|
+
)
|
|
355
|
+
.bind(value)
|
|
356
|
+
.first<ContentCandidate>()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function slugConflict(id: string, slug: string, contentId: string): Response {
|
|
360
|
+
return json({ error: 'slug_conflict', external_id: id, slug, content_id: contentId }, 409)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const BRIDGE_NAMESPACE = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
364
|
+
|
|
365
|
+
function bridgePrefix(config: ContentRouteConfig): string | null {
|
|
366
|
+
const namespace = config.foundryPublish?.bridgeSourceNamespace
|
|
367
|
+
return typeof namespace === 'string' && BRIDGE_NAMESPACE.test(namespace) ? `${namespace}:` : null
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function adoptable(
|
|
371
|
+
config: ContentRouteConfig,
|
|
372
|
+
id: string,
|
|
373
|
+
candidate: ContentCandidate,
|
|
374
|
+
matchedBy: Adoption['matched_by'],
|
|
375
|
+
): { candidate: ContentCandidate; matchedBy: Adoption['matched_by'] } | { error: Response } {
|
|
376
|
+
const reason =
|
|
377
|
+
candidate.deleted_at !== null
|
|
378
|
+
? 'deleted'
|
|
379
|
+
: candidate.type !== 'article'
|
|
380
|
+
? 'not_article'
|
|
381
|
+
: candidate.bound_external_id !== null || candidate.source_id?.startsWith(SOURCE_PREFIX)
|
|
382
|
+
? 'bound_to_another_external_id'
|
|
383
|
+
: !candidate.source_id
|
|
384
|
+
? 'not_bridge_imported'
|
|
385
|
+
: null
|
|
386
|
+
if (reason) {
|
|
387
|
+
return {
|
|
388
|
+
error: json(
|
|
389
|
+
{ error: 'adoption_conflict', external_id: id, content_id: candidate.id, reason },
|
|
390
|
+
409,
|
|
391
|
+
),
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Only the publication's own archive bridge namespace is adoptable: any
|
|
395
|
+
// other source (another importer, another publication's bridge) is refused.
|
|
396
|
+
const prefix = bridgePrefix(config)
|
|
397
|
+
if (!prefix || !candidate.source_id?.startsWith(prefix) || candidate.source_id === prefix) {
|
|
398
|
+
return {
|
|
399
|
+
error: json(
|
|
400
|
+
{
|
|
401
|
+
error: 'adoption_source_not_bridge',
|
|
402
|
+
external_id: id,
|
|
403
|
+
content_id: candidate.id,
|
|
404
|
+
source_id: candidate.source_id,
|
|
405
|
+
},
|
|
406
|
+
409,
|
|
407
|
+
),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return { candidate, matchedBy }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* One-time adoption, before anything is created: `adoptSourceId` resolves by
|
|
415
|
+
* exact source_id; without it, the verbatim slug resolves the item. Only a
|
|
416
|
+
* bridge-imported article (a non-foundry source_id) that no external id owns
|
|
417
|
+
* can be adopted. Two different items named by one request is ambiguous; a slug
|
|
418
|
+
* held by an item the request did not name is a conflict. Never a duplicate.
|
|
419
|
+
*/
|
|
420
|
+
async function resolveAdoption(
|
|
421
|
+
ctx: RouteContext,
|
|
422
|
+
config: ContentRouteConfig,
|
|
423
|
+
id: string,
|
|
424
|
+
body: FoundryArticleV2,
|
|
425
|
+
): Promise<
|
|
426
|
+
| { candidate: ContentCandidate | null; matchedBy: Adoption['matched_by'] | null }
|
|
427
|
+
| { error: Response }
|
|
428
|
+
> {
|
|
429
|
+
const slugHolder = await findCandidate(ctx, 'slug', body.slug)
|
|
430
|
+
if (body.adoptSourceId) {
|
|
431
|
+
const bySource = await findCandidate(ctx, 'source_id', body.adoptSourceId)
|
|
432
|
+
if (!bySource) {
|
|
433
|
+
if (slugHolder) return { error: slugConflict(id, body.slug, slugHolder.id) }
|
|
434
|
+
return { candidate: null, matchedBy: null }
|
|
435
|
+
}
|
|
436
|
+
if (slugHolder && slugHolder.id !== bySource.id) {
|
|
437
|
+
return {
|
|
438
|
+
error: json(
|
|
439
|
+
{
|
|
440
|
+
error: 'adoption_ambiguous',
|
|
441
|
+
external_id: id,
|
|
442
|
+
candidates: [
|
|
443
|
+
{ content_id: bySource.id, matched_by: 'source_id' },
|
|
444
|
+
{ content_id: slugHolder.id, matched_by: 'slug' },
|
|
445
|
+
],
|
|
446
|
+
},
|
|
447
|
+
409,
|
|
448
|
+
),
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return adoptable(config, id, bySource, 'source_id')
|
|
452
|
+
}
|
|
453
|
+
if (!slugHolder) return { candidate: null, matchedBy: null }
|
|
454
|
+
return adoptable(config, id, slugHolder, 'slug')
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Bind the adopted item to `foundry:<external_id>` and record the adoption in
|
|
459
|
+
* the activity ledger in ONE D1 batch (one transaction). The UPDATE is a CAS on
|
|
460
|
+
* the item's prior source_id and on it having no receipt row, so a concurrent
|
|
461
|
+
* adoption or a racing bind can never produce a second owner.
|
|
462
|
+
*/
|
|
463
|
+
async function bindAdoption(
|
|
464
|
+
ctx: RouteContext,
|
|
465
|
+
config: ContentRouteConfig,
|
|
466
|
+
id: string,
|
|
467
|
+
candidate: ContentCandidate,
|
|
468
|
+
): Promise<boolean> {
|
|
469
|
+
const sourceId = `${SOURCE_PREFIX}${id}`
|
|
470
|
+
await ctx.db.batch([
|
|
471
|
+
ctx.db
|
|
472
|
+
.prepare(
|
|
473
|
+
`UPDATE content_items SET source_id = ?, updated_at = unixepoch()
|
|
474
|
+
WHERE id = ? AND deleted_at IS NULL AND type = 'article' AND source_id = ?
|
|
475
|
+
AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)`,
|
|
476
|
+
)
|
|
477
|
+
.bind(sourceId, candidate.id, candidate.source_id, candidate.id),
|
|
478
|
+
ctx.db
|
|
479
|
+
.prepare(
|
|
480
|
+
`INSERT INTO cms_activity_log (id, workspace_id, user_id, action, target_id, target_title, type)
|
|
481
|
+
SELECT ?, ?, 'foundry', 'foundry.adopted', id, title, type
|
|
482
|
+
FROM content_items WHERE id = ? AND source_id = ? AND changes() = 1`,
|
|
483
|
+
)
|
|
484
|
+
.bind(crypto.randomUUID(), config.activeWorkspaceId ?? 'default', candidate.id, sourceId),
|
|
485
|
+
])
|
|
486
|
+
const bound = await ctx.db
|
|
487
|
+
.prepare('SELECT source_id FROM content_items WHERE id = ? LIMIT 1')
|
|
488
|
+
.bind(candidate.id)
|
|
489
|
+
.first<{ source_id: string | null }>()
|
|
490
|
+
return bound?.source_id === sourceId
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function publishFailure(id: string, error: unknown): Response {
|
|
494
|
+
if (error instanceof PublishContentValidationError) {
|
|
495
|
+
return json({ error: 'publish_validation_failed', external_id: id, errors: error.errors }, 422)
|
|
496
|
+
}
|
|
497
|
+
console.error('[cms] foundry article publish failed', {
|
|
498
|
+
externalId: id,
|
|
499
|
+
error: error instanceof Error ? error.message : String(error),
|
|
500
|
+
})
|
|
501
|
+
return json({ error: 'publish_failed', external_id: id }, 502)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const FOUNDRY_PUT_TAG = 'foundry.put:'
|
|
505
|
+
|
|
506
|
+
async function canonicalVersion(ctx: RouteContext, contentId: string): Promise<number | null> {
|
|
507
|
+
const row = await ctx.db
|
|
508
|
+
.prepare('SELECT canonical_version FROM content_items WHERE id = ? LIMIT 1')
|
|
509
|
+
.bind(contentId)
|
|
510
|
+
.first<{ canonical_version: number | null }>()
|
|
511
|
+
return row?.canonical_version ?? null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Record what this v2 PUT wrote as a History revision (the autosave/restore
|
|
516
|
+
* mechanism; it never moves the published pointer), created_by `foundry` and
|
|
517
|
+
* tagged with the request hash. POST /publish on a published item resyncs only
|
|
518
|
+
* while the item's canonical_version still equals this revision's. A snapshot
|
|
519
|
+
* that is not purely Foundry's write (another canonical write landed between
|
|
520
|
+
* the write and the snapshot) is re-tagged so it can never vouch for a resync.
|
|
521
|
+
*/
|
|
522
|
+
async function recordFoundryWrite(
|
|
523
|
+
ctx: RouteContext,
|
|
524
|
+
contentId: string,
|
|
525
|
+
bodyHash: string,
|
|
526
|
+
priorVersion: number | null,
|
|
527
|
+
): Promise<void> {
|
|
528
|
+
const revision = await createRevisionWithDelta(ctx.db, contentId, {
|
|
529
|
+
createdBy: 'foundry',
|
|
530
|
+
tag: `${FOUNDRY_PUT_TAG}${bodyHash}`,
|
|
531
|
+
})
|
|
532
|
+
if (!revision || priorVersion === null) return
|
|
533
|
+
const recorded = await ctx.db
|
|
534
|
+
.prepare(
|
|
535
|
+
`SELECT json_extract(payload_json, '$.item.canonical_version') AS version
|
|
536
|
+
FROM content_revisions WHERE id = ? LIMIT 1`,
|
|
537
|
+
)
|
|
538
|
+
.bind(revision.id)
|
|
539
|
+
.first<{ version: number | null }>()
|
|
540
|
+
if (recorded?.version !== priorVersion + 1) {
|
|
541
|
+
await ctx.db
|
|
542
|
+
.prepare("UPDATE content_revisions SET tag = 'foundry.put.unverified' WHERE id = ?")
|
|
543
|
+
.bind(revision.id)
|
|
544
|
+
.run()
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** True while the working copy is still exactly what Foundry's last v2 PUT wrote. */
|
|
549
|
+
async function lastFoundryWriteCurrent(
|
|
550
|
+
ctx: RouteContext,
|
|
551
|
+
row: FoundryArticleRow,
|
|
552
|
+
): Promise<boolean> {
|
|
553
|
+
const versions = await ctx.db
|
|
554
|
+
.prepare(
|
|
555
|
+
`SELECT c.canonical_version AS current_version,
|
|
556
|
+
(SELECT json_extract(r.payload_json, '$.item.canonical_version')
|
|
557
|
+
FROM content_revisions r
|
|
558
|
+
WHERE r.content_id = c.id AND r.created_by = 'foundry' AND r.tag = ?
|
|
559
|
+
ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1) AS put_version
|
|
560
|
+
FROM content_items c
|
|
561
|
+
WHERE c.id = ? AND c.deleted_at IS NULL
|
|
562
|
+
LIMIT 1`,
|
|
563
|
+
)
|
|
564
|
+
.bind(`${FOUNDRY_PUT_TAG}${row.request_sha256}`, row.content_id)
|
|
565
|
+
.first<{ current_version: number | null; put_version: number | null }>()
|
|
566
|
+
return (
|
|
567
|
+
versions !== null &&
|
|
568
|
+
versions.put_version !== null &&
|
|
569
|
+
versions.current_version === versions.put_version
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The v2 receipt: the four v1 fields plus the stored request hash. */
|
|
574
|
+
function v2Receipt(
|
|
575
|
+
row: Pick<FoundryArticleRow, 'external_id' | 'receipt_id' | 'status' | 'slug' | 'request_sha256'>,
|
|
576
|
+
) {
|
|
577
|
+
return { ...receipt(row), request_sha256: row.request_sha256 }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** v2 PUTs may write a machine draft or edit a published item in place (a resync). */
|
|
581
|
+
const V2_WRITABLE: ReadonlySet<FoundryArticleRow['status']> = new Set(['draft', 'published'])
|
|
582
|
+
|
|
583
|
+
async function putV2(
|
|
584
|
+
ctx: RouteContext,
|
|
585
|
+
config: ContentRouteConfig,
|
|
586
|
+
handlers: { draft: ContentHandlers; published: ContentHandlers },
|
|
587
|
+
id: string,
|
|
588
|
+
payload: unknown,
|
|
589
|
+
bodyHash: string,
|
|
590
|
+
): Promise<Response> {
|
|
591
|
+
const parsed = FoundryArticleV2Schema.safeParse(payload)
|
|
592
|
+
if (!parsed.success)
|
|
593
|
+
return json({ error: 'invalid_request', details: parsed.error.flatten() }, 400)
|
|
594
|
+
const body = parsed.data
|
|
595
|
+
|
|
596
|
+
const prior = await findArticle(ctx, id)
|
|
597
|
+
if (prior?.request_sha256 === bodyHash) {
|
|
598
|
+
console.info('[cms] foundry article request deduplicated', {
|
|
599
|
+
externalId: id,
|
|
600
|
+
receiptId: prior.receipt_id,
|
|
601
|
+
})
|
|
602
|
+
return json(v2Receipt(prior))
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let bound = prior
|
|
606
|
+
? { id: prior.content_id, status: prior.status, type: prior.type as string, slug: prior.slug }
|
|
607
|
+
: await findContentBySource(ctx, id)
|
|
608
|
+
if (bound && bound.type !== 'article') return json({ error: 'external_id_conflict' }, 409)
|
|
609
|
+
// Scheduled, review and archived items stay human-owned.
|
|
610
|
+
if (bound && !V2_WRITABLE.has(bound.status)) return approvalConflict(id, bound.status)
|
|
611
|
+
|
|
612
|
+
let adoption: Adoption | null = null
|
|
613
|
+
if (bound) {
|
|
614
|
+
const holder = await findCandidate(ctx, 'slug', body.slug)
|
|
615
|
+
if (holder && holder.id !== bound.id) return slugConflict(id, body.slug, holder.id)
|
|
616
|
+
} else {
|
|
617
|
+
const resolved = await resolveAdoption(ctx, config, id, body)
|
|
618
|
+
if ('error' in resolved) return resolved.error
|
|
619
|
+
if (resolved.candidate && resolved.matchedBy) {
|
|
620
|
+
const candidate = resolved.candidate
|
|
621
|
+
if (!(await bindAdoption(ctx, config, id, candidate))) {
|
|
622
|
+
return json(
|
|
623
|
+
{
|
|
624
|
+
error: 'adoption_conflict',
|
|
625
|
+
external_id: id,
|
|
626
|
+
content_id: candidate.id,
|
|
627
|
+
reason: 'binding_raced',
|
|
628
|
+
},
|
|
629
|
+
409,
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
adoption = {
|
|
633
|
+
content_id: candidate.id,
|
|
634
|
+
from_source_id: candidate.source_id as string,
|
|
635
|
+
matched_by: resolved.matchedBy,
|
|
636
|
+
}
|
|
637
|
+
console.info('[cms] foundry article adopted', { externalId: id, ...adoption })
|
|
638
|
+
if (!V2_WRITABLE.has(candidate.status)) {
|
|
639
|
+
return json(
|
|
640
|
+
{
|
|
641
|
+
error: 'published_requires_approval',
|
|
642
|
+
external_id: id,
|
|
643
|
+
state: candidate.status,
|
|
644
|
+
adoption,
|
|
645
|
+
},
|
|
646
|
+
409,
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
bound = {
|
|
650
|
+
id: candidate.id,
|
|
651
|
+
status: candidate.status,
|
|
652
|
+
type: candidate.type,
|
|
653
|
+
slug: candidate.slug,
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// The canonical version Foundry's write must produce is one over this.
|
|
659
|
+
const priorVersion = bound ? await canonicalVersion(ctx, bound.id) : null
|
|
660
|
+
const heroImageId = body.hero_image_url
|
|
661
|
+
? await heroAssetId(body.hero_image_url)
|
|
662
|
+
: (body.hero_image_key ?? undefined)
|
|
663
|
+
const nativeBody = {
|
|
664
|
+
title: body.title,
|
|
665
|
+
slug: body.slug,
|
|
666
|
+
...(heroImageId ? { heroImageId } : {}),
|
|
667
|
+
...(body.description !== undefined
|
|
668
|
+
? { description: body.description, excerpt: body.description }
|
|
669
|
+
: {}),
|
|
670
|
+
...(body.author !== undefined ? { byline: body.author } : {}),
|
|
671
|
+
...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
|
|
672
|
+
...(body.tags !== undefined ? { tags: body.tags } : {}),
|
|
673
|
+
...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
|
|
674
|
+
content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let nativeResponse: Response
|
|
678
|
+
if (bound) {
|
|
679
|
+
// Each write is a CAS on the status it was read in: a draft stays a
|
|
680
|
+
// draft write, and a published item is edited in place only while it is
|
|
681
|
+
// still published (its live revision changes only on POST /publish).
|
|
682
|
+
const writer = bound.status === 'published' ? handlers.published : handlers.draft
|
|
683
|
+
nativeResponse = await mapUpdateConflict(
|
|
684
|
+
await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
|
|
685
|
+
id,
|
|
686
|
+
)
|
|
687
|
+
} else {
|
|
688
|
+
try {
|
|
689
|
+
nativeResponse = await handlers.draft.create(
|
|
690
|
+
nativeContext(
|
|
691
|
+
ctx,
|
|
692
|
+
'POST',
|
|
693
|
+
{ ...nativeBody, type: 'article', sourceId: `${SOURCE_PREFIX}${id}` },
|
|
694
|
+
{},
|
|
695
|
+
),
|
|
696
|
+
)
|
|
697
|
+
} catch (error) {
|
|
698
|
+
const raced = await findContentBySource(ctx, id)
|
|
699
|
+
if (raced?.type !== 'article') throw error
|
|
700
|
+
nativeResponse = await mapUpdateConflict(
|
|
701
|
+
await handlers.draft.update(nativeContext(ctx, 'PATCH', nativeBody, { id: raced.id })),
|
|
702
|
+
id,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (!nativeResponse.ok) return nativeResponse
|
|
707
|
+
if (body.hero_image_url && heroImageId) {
|
|
708
|
+
await persistHeroAsset(ctx, config, heroImageId, body.hero_image_url)
|
|
709
|
+
}
|
|
710
|
+
const written = await findContentBySource(ctx, id)
|
|
711
|
+
if (!written) return json({ error: 'write_failed' }, 500)
|
|
712
|
+
// The native writer suffixes a taken slug; a v2 slug is verbatim or refused.
|
|
713
|
+
if (written.slug !== body.slug) return slugConflict(id, body.slug, written.id)
|
|
714
|
+
// A draft carries the requested publication time for POST /publish to act on.
|
|
715
|
+
await ctx.db
|
|
716
|
+
.prepare(
|
|
717
|
+
`UPDATE content_items SET publish_at = ?
|
|
718
|
+
WHERE id = ? AND status = 'draft' AND deleted_at IS NULL`,
|
|
719
|
+
)
|
|
720
|
+
.bind(body.publishAt ? Math.floor(Date.parse(body.publishAt) / 1_000) : null, written.id)
|
|
721
|
+
.run()
|
|
722
|
+
|
|
723
|
+
const item = await findContentBySource(ctx, id)
|
|
724
|
+
if (!item) return json({ error: 'write_failed' }, 500)
|
|
725
|
+
await recordFoundryWrite(ctx, item.id, bodyHash, priorVersion)
|
|
726
|
+
const receiptId = crypto.randomUUID()
|
|
727
|
+
await upsertReceipt(ctx, {
|
|
728
|
+
externalId: id,
|
|
729
|
+
contentId: item.id,
|
|
730
|
+
receiptId,
|
|
731
|
+
requestHash: bodyHash,
|
|
732
|
+
})
|
|
733
|
+
console.info('[cms] foundry article upserted', { externalId: id, receiptId })
|
|
734
|
+
const result = v2Receipt({
|
|
735
|
+
external_id: id,
|
|
736
|
+
receipt_id: receiptId,
|
|
737
|
+
status: item.status,
|
|
738
|
+
slug: item.slug,
|
|
739
|
+
request_sha256: bodyHash,
|
|
740
|
+
})
|
|
741
|
+
return json(adoption ? { ...result, adoption } : result)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** True when the live revision already carries the item's current canonical edit. */
|
|
745
|
+
async function publishedRevisionCurrent(ctx: RouteContext, contentId: string): Promise<boolean> {
|
|
746
|
+
const row = await ctx.db
|
|
747
|
+
.prepare(
|
|
748
|
+
`SELECT c.canonical_version AS current_version,
|
|
749
|
+
json_extract(r.payload_json, '$.item.canonical_version') AS published_version
|
|
750
|
+
FROM content_items c
|
|
751
|
+
JOIN content_revisions r ON r.id = c.published_revision_id AND r.content_id = c.id
|
|
752
|
+
WHERE c.id = ? AND c.status = 'published' AND c.deleted_at IS NULL
|
|
753
|
+
LIMIT 1`,
|
|
754
|
+
)
|
|
755
|
+
.bind(contentId)
|
|
756
|
+
.first<{ current_version: number | null; published_version: number | null }>()
|
|
757
|
+
return (
|
|
758
|
+
row !== null && row.current_version !== null && row.current_version === row.published_version
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function publishV2(
|
|
763
|
+
ctx: RouteContext,
|
|
764
|
+
config: ContentRouteConfig,
|
|
765
|
+
content: ContentHandlers,
|
|
766
|
+
id: string,
|
|
767
|
+
row: FoundryArticleRow,
|
|
768
|
+
): Promise<Response> {
|
|
769
|
+
// Idempotent: a scheduled item belongs to the engine's scheduler, and a
|
|
770
|
+
// published item whose live revision is current has nothing to resync.
|
|
771
|
+
if (row.status === 'scheduled') return json(v2Receipt(row))
|
|
772
|
+
if (row.status === 'published' && (await publishedRevisionCurrent(ctx, row.content_id))) {
|
|
773
|
+
return json(v2Receipt(row))
|
|
774
|
+
}
|
|
775
|
+
if (!V2_WRITABLE.has(row.status)) return approvalConflict(id, row.status)
|
|
776
|
+
// Publish, schedule and resync alike act on the working copy only while it is
|
|
777
|
+
// still exactly what Foundry's last v2 PUT wrote: a human edit since then (to
|
|
778
|
+
// a draft or a published item) is never machine-published or machine-scheduled.
|
|
779
|
+
if (!(await lastFoundryWriteCurrent(ctx, row))) {
|
|
780
|
+
return json({ error: 'human_edit_pending', external_id: id }, 409)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const item = await ctx.db
|
|
784
|
+
.prepare('SELECT publish_at, published_at FROM content_items WHERE id = ? LIMIT 1')
|
|
785
|
+
.bind(row.content_id)
|
|
786
|
+
.first<{ publish_at: number | null; published_at: number | null }>()
|
|
787
|
+
if (!item) return json({ error: 'not_found' }, 404)
|
|
788
|
+
const now = Math.floor(Date.now() / 1_000)
|
|
789
|
+
|
|
790
|
+
if (row.status === 'draft' && item.publish_at !== null && item.publish_at > now) {
|
|
791
|
+
// A scheduled item is published later by the site's scheduler, not here.
|
|
792
|
+
// Unless the publication declares that scheduler projects through
|
|
793
|
+
// publishOne with its live surfaces, it would publish with none: refuse
|
|
794
|
+
// at the request instead of leaving a silent gap.
|
|
795
|
+
if (config.foundryPublish?.scheduling !== 'publishOne') {
|
|
796
|
+
return json(
|
|
797
|
+
{
|
|
798
|
+
error: 'scheduling_requires_surface_scheduler',
|
|
799
|
+
external_id: id,
|
|
800
|
+
publish_at: new Date(item.publish_at * 1_000).toISOString(),
|
|
801
|
+
},
|
|
802
|
+
409,
|
|
803
|
+
)
|
|
804
|
+
}
|
|
805
|
+
const scheduled = await content.action(
|
|
806
|
+
nativeContext(
|
|
807
|
+
ctx,
|
|
808
|
+
'POST',
|
|
809
|
+
{ publishAt: new Date(item.publish_at * 1_000).toISOString() },
|
|
810
|
+
{ id: row.content_id },
|
|
811
|
+
'?action=schedule',
|
|
812
|
+
),
|
|
813
|
+
)
|
|
814
|
+
if (!scheduled.ok) return scheduled
|
|
815
|
+
} else {
|
|
816
|
+
// Fail closed before any write: a publish needs the publication's live surfaces.
|
|
817
|
+
const surfaces = await resolveLiveSurfaces(ctx, config)
|
|
818
|
+
if (!surfaces) {
|
|
819
|
+
return json({ error: 'GL_CMS_FOUNDRY_PUBLISH_UNCONFIGURED', external_id: id }, 503)
|
|
820
|
+
}
|
|
821
|
+
// A resync keeps the original publication time; a first publish takes the
|
|
822
|
+
// requested (past) time, else now.
|
|
823
|
+
const publishedAt =
|
|
824
|
+
row.status === 'published' ? (item.published_at ?? now) : (item.publish_at ?? now)
|
|
825
|
+
// The native publish action, unchanged, with only its commit step swapped
|
|
826
|
+
// for publishOne + live surfaces: readiness, body guard, dek, tag check,
|
|
827
|
+
// activity log, takeaways, FAQ and the content.published webhook all run
|
|
828
|
+
// exactly as for an editor's publish.
|
|
829
|
+
const commit: NonNullable<ContentRouteConfig['publishCommit']> = async (_ctx, input) => {
|
|
830
|
+
const published = await publishOne(ctx.db, {
|
|
831
|
+
contentId: input.contentId,
|
|
832
|
+
now: input.now,
|
|
833
|
+
publishedAt,
|
|
834
|
+
createdBy: 'foundry',
|
|
835
|
+
surfaces,
|
|
836
|
+
...(config.foundryPublish?.requiredSurfaceNames
|
|
837
|
+
? { requiredSurfaceNames: config.foundryPublish.requiredSurfaceNames }
|
|
838
|
+
: {}),
|
|
839
|
+
packageVersion: config.foundryPublish?.packageVersion ?? null,
|
|
840
|
+
})
|
|
841
|
+
console.info('[cms] foundry article published', {
|
|
842
|
+
externalId: id,
|
|
843
|
+
attemptId: published.attemptId,
|
|
844
|
+
revisionId: published.revisionId,
|
|
845
|
+
surfaceStatus: published.surfaceStatus,
|
|
846
|
+
resync: row.status === 'published',
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
const publisher = createContentRoutes({ ...machineConfig(config), publishCommit: commit })
|
|
850
|
+
let response: Response
|
|
851
|
+
try {
|
|
852
|
+
response = await publisher.action(
|
|
853
|
+
nativeContext(ctx, 'POST', undefined, { id: row.content_id }, '?action=publish'),
|
|
854
|
+
)
|
|
855
|
+
} catch (error) {
|
|
856
|
+
return publishFailure(id, error)
|
|
857
|
+
}
|
|
858
|
+
if (!response.ok) return response
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const after = await findArticle(ctx, id)
|
|
862
|
+
if (!after) return json({ error: 'write_failed' }, 500)
|
|
863
|
+
const receiptId = crypto.randomUUID()
|
|
864
|
+
await upsertReceipt(ctx, {
|
|
865
|
+
externalId: id,
|
|
866
|
+
contentId: row.content_id,
|
|
867
|
+
receiptId,
|
|
868
|
+
requestHash: row.request_sha256,
|
|
869
|
+
})
|
|
870
|
+
return json(v2Receipt({ ...after, receipt_id: receiptId }))
|
|
871
|
+
}
|
|
872
|
+
|
|
218
873
|
export function createFoundryPublishRoutes(
|
|
219
874
|
config: ContentRouteConfig,
|
|
220
875
|
): FoundryPublishRouteHandlers {
|
|
221
876
|
const content = createContentRoutes(machineConfig(config))
|
|
877
|
+
// A publication that declares a canonical host (statically, or on its
|
|
878
|
+
// opt-in) verifies host-bound only, with or without autoPublish; an opted-in
|
|
879
|
+
// one must declare it. Missing (when opted in), malformed or conflicting
|
|
880
|
+
// fails closed on every machine verb instead of falling back to the unbound
|
|
881
|
+
// form (the HMAC secret is shared by every site). Nothing declared and not
|
|
882
|
+
// opted in: the legacy form, byte-for-byte as before.
|
|
883
|
+
const declaration = declaredCanonicalHost(config)
|
|
884
|
+
const signingHost = declaration?.host ?? null
|
|
885
|
+
const hostUnconfigured = declaration ? declaration.host === null : autoPublishEnabled(config)
|
|
886
|
+
if (hostUnconfigured) {
|
|
887
|
+
console.error(
|
|
888
|
+
'[cms] the Foundry canonicalHost is missing (autoPublish is on), invalid, or conflicting',
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
const verify = (
|
|
892
|
+
ctx: RouteContext,
|
|
893
|
+
body: string,
|
|
894
|
+
): Promise<{ bodyHash: string } | { error: Response }> =>
|
|
895
|
+
hostUnconfigured
|
|
896
|
+
? Promise.resolve({
|
|
897
|
+
error: json(
|
|
898
|
+
{
|
|
899
|
+
error: 'GL_CMS_FOUNDRY_HOST_UNCONFIGURED',
|
|
900
|
+
detail:
|
|
901
|
+
'a declared canonicalHost must be valid and agree, and is required when autoPublish is on',
|
|
902
|
+
},
|
|
903
|
+
503,
|
|
904
|
+
),
|
|
905
|
+
})
|
|
906
|
+
: authenticate(ctx, body, signingHost)
|
|
907
|
+
// Built only for an opted-in publication: the in-place writer for a published item.
|
|
908
|
+
const publishedWriter = autoPublishEnabled(config)
|
|
909
|
+
? createContentRoutes({ ...machineConfig(config), updateStatusConstraint: 'published' })
|
|
910
|
+
: null
|
|
222
911
|
|
|
223
912
|
return {
|
|
224
913
|
async put(ctx) {
|
|
225
914
|
const id = externalId(ctx)
|
|
226
915
|
if (!id) return json({ error: 'invalid_external_id' }, 400)
|
|
227
916
|
const rawBody = await ctx.request.text()
|
|
228
|
-
const auth = await
|
|
917
|
+
const auth = await verify(ctx, rawBody)
|
|
229
918
|
if ('error' in auth) return auth.error
|
|
230
919
|
let payload: unknown
|
|
231
920
|
try {
|
|
@@ -233,6 +922,16 @@ export function createFoundryPublishRoutes(
|
|
|
233
922
|
} catch {
|
|
234
923
|
return json({ error: 'invalid_json' }, 400)
|
|
235
924
|
}
|
|
925
|
+
if (autoPublishEnabled(config) && isV2Payload(payload)) {
|
|
926
|
+
return putV2(
|
|
927
|
+
ctx,
|
|
928
|
+
config,
|
|
929
|
+
{ draft: content, published: publishedWriter ?? content },
|
|
930
|
+
id,
|
|
931
|
+
payload,
|
|
932
|
+
auth.bodyHash,
|
|
933
|
+
)
|
|
934
|
+
}
|
|
236
935
|
const parsed = PutSchema.safeParse(payload)
|
|
237
936
|
if (!parsed.success)
|
|
238
937
|
return json({ error: 'invalid_request', details: parsed.error.flatten() }, 400)
|
|
@@ -284,7 +983,7 @@ export function createFoundryPublishRoutes(
|
|
|
284
983
|
)
|
|
285
984
|
} catch (error) {
|
|
286
985
|
const raced = await findContentBySource(ctx, id)
|
|
287
|
-
if (
|
|
986
|
+
if (raced?.type !== 'article') throw error
|
|
288
987
|
nativeResponse = await mapUpdateConflict(
|
|
289
988
|
await content.update(nativeContext(ctx, 'PATCH', body, { id: raced.id })),
|
|
290
989
|
id,
|
|
@@ -320,11 +1019,12 @@ export function createFoundryPublishRoutes(
|
|
|
320
1019
|
async get(ctx) {
|
|
321
1020
|
const id = externalId(ctx)
|
|
322
1021
|
if (!id) return json({ error: 'invalid_external_id' }, 400)
|
|
323
|
-
const auth = await
|
|
1022
|
+
const auth = await verify(ctx, '')
|
|
324
1023
|
if ('error' in auth) return auth.error
|
|
325
1024
|
const row = await findArticle(ctx, id)
|
|
326
1025
|
if (!row) return json({ error: 'not_found' }, 404)
|
|
327
1026
|
console.info('[cms] foundry article read', { externalId: id, receiptId: row.receipt_id })
|
|
1027
|
+
if (autoPublishEnabled(config)) return json(v2Receipt(row))
|
|
328
1028
|
return json(receipt(row))
|
|
329
1029
|
},
|
|
330
1030
|
|
|
@@ -332,7 +1032,7 @@ export function createFoundryPublishRoutes(
|
|
|
332
1032
|
const id = externalId(ctx)
|
|
333
1033
|
if (!id) return json({ error: 'invalid_external_id' }, 400)
|
|
334
1034
|
const rawBody = await ctx.request.text()
|
|
335
|
-
const auth = await
|
|
1035
|
+
const auth = await verify(ctx, rawBody)
|
|
336
1036
|
if ('error' in auth) return auth.error
|
|
337
1037
|
const row = await findArticle(ctx, id)
|
|
338
1038
|
if (!row) return json({ error: 'not_found' }, 404)
|
|
@@ -352,5 +1052,22 @@ export function createFoundryPublishRoutes(
|
|
|
352
1052
|
console.info('[cms] foundry article unpublished', { externalId: id, receiptId })
|
|
353
1053
|
return json(receipt(result))
|
|
354
1054
|
},
|
|
1055
|
+
|
|
1056
|
+
async publish(ctx) {
|
|
1057
|
+
const id = externalId(ctx)
|
|
1058
|
+
if (!id) return json({ error: 'invalid_external_id' }, 400)
|
|
1059
|
+
const rawBody = await ctx.request.text()
|
|
1060
|
+
const auth = await verify(ctx, rawBody)
|
|
1061
|
+
if ('error' in auth) return auth.error
|
|
1062
|
+
// POST /publish takes no body: anything sent is refused, never ignored.
|
|
1063
|
+
if (rawBody.length > 0 || !/^0?$/.test(ctx.request.headers.get('Content-Length') ?? '')) {
|
|
1064
|
+
return json({ error: 'invalid_request', detail: 'publish takes no request body' }, 400)
|
|
1065
|
+
}
|
|
1066
|
+
const row = await findArticle(ctx, id)
|
|
1067
|
+
if (!row) return json({ error: 'not_found' }, 404)
|
|
1068
|
+
// Without the publication's opt-in the machine route stays draft-only.
|
|
1069
|
+
if (!autoPublishEnabled(config)) return approvalConflict(id, row.status)
|
|
1070
|
+
return publishV2(ctx, config, content, id, row)
|
|
1071
|
+
},
|
|
355
1072
|
}
|
|
356
1073
|
}
|