@growth-labs/cms 0.8.20 → 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.
Files changed (172) hide show
  1. package/dist/cli/foundry-emit.d.ts +14 -0
  2. package/dist/cli/foundry-emit.d.ts.map +1 -0
  3. package/dist/cli/foundry-emit.js +69 -0
  4. package/dist/cli/foundry-emit.js.map +1 -0
  5. package/dist/engine/fronts-publish-intent.d.ts +1 -1
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  7. package/dist/engine/fronts-publish-intent.js +12 -1
  8. package/dist/engine/fronts-publish-intent.js.map +1 -1
  9. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  10. package/dist/engine/publisher.d.ts +16 -1
  11. package/dist/engine/publisher.d.ts.map +1 -1
  12. package/dist/engine/publisher.js +55 -4
  13. package/dist/engine/publisher.js.map +1 -1
  14. package/dist/engine/tags.d.ts.map +1 -1
  15. package/dist/engine/tags.js +18 -1
  16. package/dist/engine/tags.js.map +1 -1
  17. package/dist/engine/topics.d.ts.map +1 -1
  18. package/dist/engine/topics.js +4 -1
  19. package/dist/engine/topics.js.map +1 -1
  20. package/dist/engine/video-source.d.ts +21 -0
  21. package/dist/engine/video-source.d.ts.map +1 -1
  22. package/dist/engine/video-source.js +40 -0
  23. package/dist/engine/video-source.js.map +1 -1
  24. package/dist/foundry-emit.d.ts +146 -0
  25. package/dist/foundry-emit.d.ts.map +1 -0
  26. package/dist/foundry-emit.js +189 -0
  27. package/dist/foundry-emit.js.map +1 -0
  28. package/dist/integration/index.d.ts.map +1 -1
  29. package/dist/integration/index.js +9 -0
  30. package/dist/integration/index.js.map +1 -1
  31. package/dist/integration/options.d.ts +24 -0
  32. package/dist/integration/options.d.ts.map +1 -1
  33. package/dist/integration/options.js +22 -0
  34. package/dist/integration/options.js.map +1 -1
  35. package/dist/providers/null.d.ts.map +1 -1
  36. package/dist/providers/null.js +6 -0
  37. package/dist/providers/null.js.map +1 -1
  38. package/dist/providers/types.d.ts +56 -0
  39. package/dist/providers/types.d.ts.map +1 -1
  40. package/dist/providers/types.js.map +1 -1
  41. package/dist/routes/ai.d.ts.map +1 -1
  42. package/dist/routes/ai.js +21 -13
  43. package/dist/routes/ai.js.map +1 -1
  44. package/dist/routes/analytics.d.ts +1 -0
  45. package/dist/routes/analytics.d.ts.map +1 -1
  46. package/dist/routes/analytics.js +20 -0
  47. package/dist/routes/analytics.js.map +1 -1
  48. package/dist/routes/content.d.ts +27 -0
  49. package/dist/routes/content.d.ts.map +1 -1
  50. package/dist/routes/content.js +58 -12
  51. package/dist/routes/content.js.map +1 -1
  52. package/dist/routes/foundry-publish.d.ts +50 -0
  53. package/dist/routes/foundry-publish.d.ts.map +1 -1
  54. package/dist/routes/foundry-publish.js +540 -27
  55. package/dist/routes/foundry-publish.js.map +1 -1
  56. package/dist/routes/machine-context.d.ts +6 -0
  57. package/dist/routes/machine-context.d.ts.map +1 -0
  58. package/dist/routes/machine-context.js +20 -0
  59. package/dist/routes/machine-context.js.map +1 -0
  60. package/dist/routes/surveys.d.ts +14 -1
  61. package/dist/routes/surveys.d.ts.map +1 -1
  62. package/dist/routes/surveys.js +515 -10
  63. package/dist/routes/surveys.js.map +1 -1
  64. package/dist/schema/insights-ingest.d.ts +8 -8
  65. package/dist/schema/migrations.d.ts.map +1 -1
  66. package/dist/schema/migrations.js +15 -0
  67. package/dist/schema/migrations.js.map +1 -1
  68. package/dist/schema/tables.d.ts +1 -1
  69. package/dist/schema/tables.d.ts.map +1 -1
  70. package/dist/schema/tables.js +2 -0
  71. package/dist/schema/tables.js.map +1 -1
  72. package/dist/surveys/access.d.ts +1 -0
  73. package/dist/surveys/access.d.ts.map +1 -1
  74. package/dist/surveys/access.js +13 -4
  75. package/dist/surveys/access.js.map +1 -1
  76. package/dist/surveys/auto-label.d.ts +100 -0
  77. package/dist/surveys/auto-label.d.ts.map +1 -0
  78. package/dist/surveys/auto-label.js +325 -0
  79. package/dist/surveys/auto-label.js.map +1 -0
  80. package/dist/surveys/schema.d.ts +38 -12
  81. package/dist/surveys/schema.d.ts.map +1 -1
  82. package/dist/surveys/schema.js +18 -1
  83. package/dist/surveys/schema.js.map +1 -1
  84. package/dist/ui/api/_content-config.d.ts.map +1 -1
  85. package/dist/ui/api/_content-config.js +4 -0
  86. package/dist/ui/api/_content-config.js.map +1 -1
  87. package/dist/ui/api/analytics.d.ts.map +1 -1
  88. package/dist/ui/api/analytics.js +5 -2
  89. package/dist/ui/api/analytics.js.map +1 -1
  90. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  91. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  92. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  93. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  94. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  95. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  96. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  97. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  99. package/dist/ui/api/surveys/[surveyId].js +28 -1
  100. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  101. package/dist/ui/commands.d.ts.map +1 -1
  102. package/dist/ui/commands.js +2 -0
  103. package/dist/ui/commands.js.map +1 -1
  104. package/dist/ui/hash-router.d.ts +1 -1
  105. package/dist/ui/hash-router.d.ts.map +1 -1
  106. package/dist/ui/hash-router.js +1 -0
  107. package/dist/ui/hash-router.js.map +1 -1
  108. package/dist/ui/nav.d.ts.map +1 -1
  109. package/dist/ui/nav.js +1 -0
  110. package/dist/ui/nav.js.map +1 -1
  111. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  112. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  113. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  114. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  115. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  116. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  117. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  118. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  119. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  120. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  122. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  123. package/dist/ui/screens/analytics-data.d.ts +14 -0
  124. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  125. package/dist/ui/screens/analytics-data.js +16 -0
  126. package/dist/ui/screens/analytics-data.js.map +1 -1
  127. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  128. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  129. package/dist/ui/screens/podcast-listening-data.js +98 -0
  130. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  131. package/dist/ui/screens/registry.d.ts.map +1 -1
  132. package/dist/ui/screens/registry.js +2 -0
  133. package/dist/ui/screens/registry.js.map +1 -1
  134. package/docs/foundry-publish.md +208 -0
  135. package/migrations/0033_survey_answer_labels.sql +15 -0
  136. package/package.json +9 -3
  137. package/src/cli/foundry-emit.ts +85 -0
  138. package/src/engine/fronts-publish-intent.ts +23 -1
  139. package/src/engine/publisher.ts +80 -4
  140. package/src/engine/tags.ts +21 -1
  141. package/src/engine/topics.ts +4 -1
  142. package/src/engine/video-source.ts +44 -0
  143. package/src/foundry-emit.ts +255 -0
  144. package/src/integration/index.ts +9 -0
  145. package/src/integration/options.ts +23 -0
  146. package/src/providers/null.ts +6 -0
  147. package/src/providers/types.ts +52 -0
  148. package/src/routes/ai.ts +29 -17
  149. package/src/routes/analytics.ts +21 -0
  150. package/src/routes/content.ts +81 -15
  151. package/src/routes/foundry-publish.ts +746 -29
  152. package/src/routes/machine-context.ts +24 -0
  153. package/src/routes/surveys.ts +587 -12
  154. package/src/schema/migrations.ts +15 -0
  155. package/src/schema/tables.ts +2 -0
  156. package/src/surveys/access.ts +14 -4
  157. package/src/surveys/auto-label.ts +420 -0
  158. package/src/surveys/schema.ts +25 -1
  159. package/src/ui/api/_content-config.ts +6 -0
  160. package/src/ui/api/analytics.ts +5 -2
  161. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  162. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  163. package/src/ui/api/surveys/[surveyId].ts +37 -3
  164. package/src/ui/commands.ts +2 -0
  165. package/src/ui/hash-router.ts +1 -0
  166. package/src/ui/nav.ts +1 -0
  167. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  168. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  169. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  170. package/src/ui/screens/analytics-data.ts +18 -0
  171. package/src/ui/screens/podcast-listening-data.ts +114 -0
  172. 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 { verifyHmac } from '../engine/foundry-dispatch.js'
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
- const timestamp = (ctx.request.headers.get('X-Foundry-Timestamp') ?? '').trim()
62
- if (!/^\d{1,15}$/.test(timestamp)) return { error: json({ error: 'unauthorized' }, 401) }
63
- const seconds = Number.parseInt(timestamp, 10)
64
- if (
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 signed = `${ctx.request.method}.${path}.${timestamp}.${bodyHash}`
74
- const signature = ctx.request.headers.get('X-Foundry-Signature') ?? ''
75
- if (!(await verifyHmac(secret, signed, signature))) {
76
- console.warn('[cms] foundry article request rejected: invalid signature', { path })
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
- return {
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 sha256(url)).slice(0, 24)}`
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 authenticate(ctx, rawBody)
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 (!raced || raced.type !== 'article') throw error
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 authenticate(ctx, '')
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 authenticate(ctx, rawBody)
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
  }