@growth-labs/cms 0.8.19 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) 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/lib/publish-timezone.d.ts +38 -0
  36. package/dist/lib/publish-timezone.d.ts.map +1 -0
  37. package/dist/lib/publish-timezone.js +135 -0
  38. package/dist/lib/publish-timezone.js.map +1 -0
  39. package/dist/providers/null.d.ts.map +1 -1
  40. package/dist/providers/null.js +6 -0
  41. package/dist/providers/null.js.map +1 -1
  42. package/dist/providers/types.d.ts +56 -0
  43. package/dist/providers/types.d.ts.map +1 -1
  44. package/dist/providers/types.js.map +1 -1
  45. package/dist/routes/ai.d.ts.map +1 -1
  46. package/dist/routes/ai.js +21 -13
  47. package/dist/routes/ai.js.map +1 -1
  48. package/dist/routes/analytics.d.ts +1 -0
  49. package/dist/routes/analytics.d.ts.map +1 -1
  50. package/dist/routes/analytics.js +20 -0
  51. package/dist/routes/analytics.js.map +1 -1
  52. package/dist/routes/content.d.ts +27 -0
  53. package/dist/routes/content.d.ts.map +1 -1
  54. package/dist/routes/content.js +60 -114
  55. package/dist/routes/content.js.map +1 -1
  56. package/dist/routes/foundry-publish.d.ts +50 -0
  57. package/dist/routes/foundry-publish.d.ts.map +1 -1
  58. package/dist/routes/foundry-publish.js +540 -27
  59. package/dist/routes/foundry-publish.js.map +1 -1
  60. package/dist/routes/machine-context.d.ts +6 -0
  61. package/dist/routes/machine-context.d.ts.map +1 -0
  62. package/dist/routes/machine-context.js +20 -0
  63. package/dist/routes/machine-context.js.map +1 -0
  64. package/dist/routes/surveys.d.ts +14 -1
  65. package/dist/routes/surveys.d.ts.map +1 -1
  66. package/dist/routes/surveys.js +515 -10
  67. package/dist/routes/surveys.js.map +1 -1
  68. package/dist/schema/insights-ingest.d.ts +8 -8
  69. package/dist/schema/migrations.d.ts.map +1 -1
  70. package/dist/schema/migrations.js +15 -0
  71. package/dist/schema/migrations.js.map +1 -1
  72. package/dist/schema/tables.d.ts +1 -1
  73. package/dist/schema/tables.d.ts.map +1 -1
  74. package/dist/schema/tables.js +2 -0
  75. package/dist/schema/tables.js.map +1 -1
  76. package/dist/surveys/access.d.ts +1 -0
  77. package/dist/surveys/access.d.ts.map +1 -1
  78. package/dist/surveys/access.js +13 -4
  79. package/dist/surveys/access.js.map +1 -1
  80. package/dist/surveys/auto-label.d.ts +100 -0
  81. package/dist/surveys/auto-label.d.ts.map +1 -0
  82. package/dist/surveys/auto-label.js +325 -0
  83. package/dist/surveys/auto-label.js.map +1 -0
  84. package/dist/surveys/schema.d.ts +38 -12
  85. package/dist/surveys/schema.d.ts.map +1 -1
  86. package/dist/surveys/schema.js +18 -1
  87. package/dist/surveys/schema.js.map +1 -1
  88. package/dist/ui/api/_content-config.d.ts.map +1 -1
  89. package/dist/ui/api/_content-config.js +4 -0
  90. package/dist/ui/api/_content-config.js.map +1 -1
  91. package/dist/ui/api/analytics.d.ts.map +1 -1
  92. package/dist/ui/api/analytics.js +5 -2
  93. package/dist/ui/api/analytics.js.map +1 -1
  94. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  95. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  96. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  97. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  99. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  100. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  101. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  102. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  103. package/dist/ui/api/surveys/[surveyId].js +28 -1
  104. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  105. package/dist/ui/commands.d.ts.map +1 -1
  106. package/dist/ui/commands.js +2 -0
  107. package/dist/ui/commands.js.map +1 -1
  108. package/dist/ui/hash-router.d.ts +1 -1
  109. package/dist/ui/hash-router.d.ts.map +1 -1
  110. package/dist/ui/hash-router.js +1 -0
  111. package/dist/ui/hash-router.js.map +1 -1
  112. package/dist/ui/inspector/PublishTab.d.ts +7 -1
  113. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  114. package/dist/ui/inspector/PublishTab.js +24 -26
  115. package/dist/ui/inspector/PublishTab.js.map +1 -1
  116. package/dist/ui/nav.d.ts.map +1 -1
  117. package/dist/ui/nav.js +1 -0
  118. package/dist/ui/nav.js.map +1 -1
  119. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  120. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  122. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  123. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  124. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  125. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  126. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  127. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  128. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  129. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  130. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  131. package/dist/ui/screens/analytics-data.d.ts +14 -0
  132. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  133. package/dist/ui/screens/analytics-data.js +16 -0
  134. package/dist/ui/screens/analytics-data.js.map +1 -1
  135. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  136. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  137. package/dist/ui/screens/podcast-listening-data.js +98 -0
  138. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  139. package/dist/ui/screens/registry.d.ts.map +1 -1
  140. package/dist/ui/screens/registry.js +2 -0
  141. package/dist/ui/screens/registry.js.map +1 -1
  142. package/docs/foundry-publish.md +208 -0
  143. package/migrations/0033_survey_answer_labels.sql +15 -0
  144. package/package.json +9 -3
  145. package/src/cli/foundry-emit.ts +85 -0
  146. package/src/engine/fronts-publish-intent.ts +23 -1
  147. package/src/engine/publisher.ts +80 -4
  148. package/src/engine/tags.ts +21 -1
  149. package/src/engine/topics.ts +4 -1
  150. package/src/engine/video-source.ts +44 -0
  151. package/src/foundry-emit.ts +255 -0
  152. package/src/integration/index.ts +9 -0
  153. package/src/integration/options.ts +23 -0
  154. package/src/lib/publish-timezone.ts +154 -0
  155. package/src/providers/null.ts +6 -0
  156. package/src/providers/types.ts +52 -0
  157. package/src/routes/ai.ts +29 -17
  158. package/src/routes/analytics.ts +21 -0
  159. package/src/routes/content.ts +86 -131
  160. package/src/routes/foundry-publish.ts +746 -29
  161. package/src/routes/machine-context.ts +24 -0
  162. package/src/routes/surveys.ts +587 -12
  163. package/src/schema/migrations.ts +15 -0
  164. package/src/schema/tables.ts +2 -0
  165. package/src/surveys/access.ts +14 -4
  166. package/src/surveys/auto-label.ts +420 -0
  167. package/src/surveys/schema.ts +25 -1
  168. package/src/ui/api/_content-config.ts +6 -0
  169. package/src/ui/api/analytics.ts +5 -2
  170. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  171. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  172. package/src/ui/api/surveys/[surveyId].ts +37 -3
  173. package/src/ui/commands.ts +2 -0
  174. package/src/ui/hash-router.ts +1 -0
  175. package/src/ui/inspector/PublishTab.tsx +33 -25
  176. package/src/ui/nav.ts +1 -0
  177. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  178. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  179. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  180. package/src/ui/screens/analytics-data.ts +18 -0
  181. package/src/ui/screens/podcast-listening-data.ts +114 -0
  182. package/src/ui/screens/registry.tsx +2 -0
@@ -1,10 +1,14 @@
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 { 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 { FoundryArticleV2Schema } from '../foundry-emit.js';
4
8
  import { createContentRoutes } from './content.js';
5
9
  import { json } from './context.js';
10
+ import { markFoundryMachineContext } from './machine-context.js';
6
11
  import { insertMediaAsset } from './media-lib.js';
7
- const MAX_SKEW_SECONDS = 300;
8
12
  const SOURCE_PREFIX = 'foundry:';
9
13
  const PutSchema = z
10
14
  .object({
@@ -28,30 +32,28 @@ async function resolveSecret(env) {
28
32
  }
29
33
  return null;
30
34
  }
31
- async function sha256(value) {
32
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
33
- return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
34
- }
35
- async function authenticate(ctx, body) {
35
+ async function authenticate(ctx, body, canonicalHost = null) {
36
36
  const secret = await resolveSecret(ctx.env);
37
37
  if (!secret)
38
38
  return { error: json({ error: 'GL_CMS_FOUNDRY_UNCONFIGURED' }, 503) };
39
- const timestamp = (ctx.request.headers.get('X-Foundry-Timestamp') ?? '').trim();
40
- if (!/^\d{1,15}$/.test(timestamp))
41
- return { error: json({ error: 'unauthorized' }, 401) };
42
- const seconds = Number.parseInt(timestamp, 10);
43
- if (!Number.isSafeInteger(seconds) ||
44
- Math.abs(Math.floor(Date.now() / 1_000) - seconds) > MAX_SKEW_SECONDS) {
45
- return { error: json({ error: 'unauthorized' }, 401) };
46
- }
47
- const bodyHash = await sha256(body);
39
+ // The one Foundry route-family verifier (@growth-labs/foundry-signing): the
40
+ // host-bound foundry-cms-v2 string with this publication's CONFIGURED
41
+ // canonical host when it opted in (M3-18), else the legacy dot string,
42
+ // byte-for-byte as before.
48
43
  const path = new URL(ctx.request.url).pathname;
49
- const signed = `${ctx.request.method}.${path}.${timestamp}.${bodyHash}`;
50
- const signature = ctx.request.headers.get('X-Foundry-Signature') ?? '';
51
- if (!(await verifyHmac(secret, signed, signature))) {
52
- console.warn('[cms] foundry article request rejected: invalid signature', { path });
44
+ const verified = await verifyFoundryRequest(secret, {
45
+ method: ctx.request.method,
46
+ host: canonicalHost,
47
+ path,
48
+ timestampHeader: ctx.request.headers.get('X-Foundry-Timestamp'),
49
+ signatureHeader: ctx.request.headers.get('X-Foundry-Signature'),
50
+ body,
51
+ });
52
+ if (!verified) {
53
+ console.warn('[cms] foundry article request rejected', { path });
53
54
  return { error: json({ error: 'unauthorized' }, 401) };
54
55
  }
56
+ const bodyHash = await sha256Hex(body);
55
57
  return { bodyHash };
56
58
  }
57
59
  function externalId(ctx) {
@@ -97,7 +99,9 @@ function machineConfig(config) {
97
99
  };
98
100
  }
99
101
  function nativeContext(ctx, method, body, params, query = '') {
100
- return {
102
+ // Every caller has already verified the Foundry HMAC: the native context it
103
+ // builds is the machine's, and is marked as such (see machine-context.ts).
104
+ return markFoundryMachineContext({
101
105
  ...ctx,
102
106
  request: new Request(`${new URL(ctx.request.url).origin}/internal/cms${query}`, {
103
107
  method,
@@ -106,7 +110,7 @@ function nativeContext(ctx, method, body, params, query = '') {
106
110
  }),
107
111
  params,
108
112
  userId: 'foundry',
109
- };
113
+ });
110
114
  }
111
115
  async function upsertReceipt(ctx, values) {
112
116
  await ctx.db
@@ -122,7 +126,7 @@ async function upsertReceipt(ctx, values) {
122
126
  .run();
123
127
  }
124
128
  async function heroAssetId(url) {
125
- return `foundry-hero-${(await sha256(url)).slice(0, 24)}`;
129
+ return `foundry-hero-${(await sha256Hex(url)).slice(0, 24)}`;
126
130
  }
127
131
  async function persistHeroAsset(ctx, config, id, url) {
128
132
  await insertMediaAsset(ctx.db, {
@@ -158,15 +162,499 @@ async function mapUpdateConflict(response, id) {
158
162
  }
159
163
  return response;
160
164
  }
165
+ function autoPublishEnabled(config) {
166
+ return config.foundryPublish?.autoPublish === true;
167
+ }
168
+ /**
169
+ * The canonical host this publication declared for the Foundry route family:
170
+ * the static `foundryCanonicalHost` and/or the opt-in's
171
+ * `foundryPublish.canonicalHost`. `null` when neither is declared. A declared
172
+ * host is never downgraded to the legacy form: a malformed host, or two that
173
+ * disagree, resolve to `{ host: null }`, which fails closed.
174
+ */
175
+ function declaredCanonicalHost(config) {
176
+ const declared = [config.foundryCanonicalHost, config.foundryPublish?.canonicalHost].filter((value) => value !== undefined);
177
+ if (declared.length === 0)
178
+ return null;
179
+ const [first] = declared;
180
+ const agreed = declared.every((value) => typeof value === 'string' && isCanonicalHost(value) && value === first);
181
+ return { host: agreed ? first : null };
182
+ }
183
+ function isV2Payload(payload) {
184
+ return (typeof payload === 'object' &&
185
+ payload !== null &&
186
+ !Array.isArray(payload) &&
187
+ payload.schemaVersion === 2);
188
+ }
189
+ async function resolveLiveSurfaces(ctx, config) {
190
+ const resolve = config.foundryPublish?.surfaces;
191
+ if (typeof resolve !== 'function')
192
+ return null;
193
+ try {
194
+ const surfaces = await resolve(ctx);
195
+ return Array.isArray(surfaces) && surfaces.length > 0 ? surfaces : null;
196
+ }
197
+ catch (error) {
198
+ console.error('[cms] foundry publish surfaces could not be resolved', error);
199
+ return null;
200
+ }
201
+ }
202
+ async function findCandidate(ctx, column, value) {
203
+ return ctx.db
204
+ .prepare(`SELECT c.id, c.status, c.type, c.slug, c.source_id, c.deleted_at,
205
+ f.external_id AS bound_external_id
206
+ FROM content_items c
207
+ LEFT JOIN cms_foundry_articles f ON f.content_id = c.id
208
+ WHERE c.${column} = ?
209
+ LIMIT 1`)
210
+ .bind(value)
211
+ .first();
212
+ }
213
+ function slugConflict(id, slug, contentId) {
214
+ return json({ error: 'slug_conflict', external_id: id, slug, content_id: contentId }, 409);
215
+ }
216
+ const BRIDGE_NAMESPACE = /^[a-z0-9][a-z0-9-]{0,63}$/;
217
+ function bridgePrefix(config) {
218
+ const namespace = config.foundryPublish?.bridgeSourceNamespace;
219
+ return typeof namespace === 'string' && BRIDGE_NAMESPACE.test(namespace) ? `${namespace}:` : null;
220
+ }
221
+ function adoptable(config, id, candidate, matchedBy) {
222
+ const reason = candidate.deleted_at !== null
223
+ ? 'deleted'
224
+ : candidate.type !== 'article'
225
+ ? 'not_article'
226
+ : candidate.bound_external_id !== null || candidate.source_id?.startsWith(SOURCE_PREFIX)
227
+ ? 'bound_to_another_external_id'
228
+ : !candidate.source_id
229
+ ? 'not_bridge_imported'
230
+ : null;
231
+ if (reason) {
232
+ return {
233
+ error: json({ error: 'adoption_conflict', external_id: id, content_id: candidate.id, reason }, 409),
234
+ };
235
+ }
236
+ // Only the publication's own archive bridge namespace is adoptable: any
237
+ // other source (another importer, another publication's bridge) is refused.
238
+ const prefix = bridgePrefix(config);
239
+ if (!prefix || !candidate.source_id?.startsWith(prefix) || candidate.source_id === prefix) {
240
+ return {
241
+ error: json({
242
+ error: 'adoption_source_not_bridge',
243
+ external_id: id,
244
+ content_id: candidate.id,
245
+ source_id: candidate.source_id,
246
+ }, 409),
247
+ };
248
+ }
249
+ return { candidate, matchedBy };
250
+ }
251
+ /**
252
+ * One-time adoption, before anything is created: `adoptSourceId` resolves by
253
+ * exact source_id; without it, the verbatim slug resolves the item. Only a
254
+ * bridge-imported article (a non-foundry source_id) that no external id owns
255
+ * can be adopted. Two different items named by one request is ambiguous; a slug
256
+ * held by an item the request did not name is a conflict. Never a duplicate.
257
+ */
258
+ async function resolveAdoption(ctx, config, id, body) {
259
+ const slugHolder = await findCandidate(ctx, 'slug', body.slug);
260
+ if (body.adoptSourceId) {
261
+ const bySource = await findCandidate(ctx, 'source_id', body.adoptSourceId);
262
+ if (!bySource) {
263
+ if (slugHolder)
264
+ return { error: slugConflict(id, body.slug, slugHolder.id) };
265
+ return { candidate: null, matchedBy: null };
266
+ }
267
+ if (slugHolder && slugHolder.id !== bySource.id) {
268
+ return {
269
+ error: json({
270
+ error: 'adoption_ambiguous',
271
+ external_id: id,
272
+ candidates: [
273
+ { content_id: bySource.id, matched_by: 'source_id' },
274
+ { content_id: slugHolder.id, matched_by: 'slug' },
275
+ ],
276
+ }, 409),
277
+ };
278
+ }
279
+ return adoptable(config, id, bySource, 'source_id');
280
+ }
281
+ if (!slugHolder)
282
+ return { candidate: null, matchedBy: null };
283
+ return adoptable(config, id, slugHolder, 'slug');
284
+ }
285
+ /**
286
+ * Bind the adopted item to `foundry:<external_id>` and record the adoption in
287
+ * the activity ledger in ONE D1 batch (one transaction). The UPDATE is a CAS on
288
+ * the item's prior source_id and on it having no receipt row, so a concurrent
289
+ * adoption or a racing bind can never produce a second owner.
290
+ */
291
+ async function bindAdoption(ctx, config, id, candidate) {
292
+ const sourceId = `${SOURCE_PREFIX}${id}`;
293
+ await ctx.db.batch([
294
+ ctx.db
295
+ .prepare(`UPDATE content_items SET source_id = ?, updated_at = unixepoch()
296
+ WHERE id = ? AND deleted_at IS NULL AND type = 'article' AND source_id = ?
297
+ AND NOT EXISTS (SELECT 1 FROM cms_foundry_articles WHERE content_id = ?)`)
298
+ .bind(sourceId, candidate.id, candidate.source_id, candidate.id),
299
+ ctx.db
300
+ .prepare(`INSERT INTO cms_activity_log (id, workspace_id, user_id, action, target_id, target_title, type)
301
+ SELECT ?, ?, 'foundry', 'foundry.adopted', id, title, type
302
+ FROM content_items WHERE id = ? AND source_id = ? AND changes() = 1`)
303
+ .bind(crypto.randomUUID(), config.activeWorkspaceId ?? 'default', candidate.id, sourceId),
304
+ ]);
305
+ const bound = await ctx.db
306
+ .prepare('SELECT source_id FROM content_items WHERE id = ? LIMIT 1')
307
+ .bind(candidate.id)
308
+ .first();
309
+ return bound?.source_id === sourceId;
310
+ }
311
+ function publishFailure(id, error) {
312
+ if (error instanceof PublishContentValidationError) {
313
+ return json({ error: 'publish_validation_failed', external_id: id, errors: error.errors }, 422);
314
+ }
315
+ console.error('[cms] foundry article publish failed', {
316
+ externalId: id,
317
+ error: error instanceof Error ? error.message : String(error),
318
+ });
319
+ return json({ error: 'publish_failed', external_id: id }, 502);
320
+ }
321
+ const FOUNDRY_PUT_TAG = 'foundry.put:';
322
+ async function canonicalVersion(ctx, contentId) {
323
+ const row = await ctx.db
324
+ .prepare('SELECT canonical_version FROM content_items WHERE id = ? LIMIT 1')
325
+ .bind(contentId)
326
+ .first();
327
+ return row?.canonical_version ?? null;
328
+ }
329
+ /**
330
+ * Record what this v2 PUT wrote as a History revision (the autosave/restore
331
+ * mechanism; it never moves the published pointer), created_by `foundry` and
332
+ * tagged with the request hash. POST /publish on a published item resyncs only
333
+ * while the item's canonical_version still equals this revision's. A snapshot
334
+ * that is not purely Foundry's write (another canonical write landed between
335
+ * the write and the snapshot) is re-tagged so it can never vouch for a resync.
336
+ */
337
+ async function recordFoundryWrite(ctx, contentId, bodyHash, priorVersion) {
338
+ const revision = await createRevisionWithDelta(ctx.db, contentId, {
339
+ createdBy: 'foundry',
340
+ tag: `${FOUNDRY_PUT_TAG}${bodyHash}`,
341
+ });
342
+ if (!revision || priorVersion === null)
343
+ return;
344
+ const recorded = await ctx.db
345
+ .prepare(`SELECT json_extract(payload_json, '$.item.canonical_version') AS version
346
+ FROM content_revisions WHERE id = ? LIMIT 1`)
347
+ .bind(revision.id)
348
+ .first();
349
+ if (recorded?.version !== priorVersion + 1) {
350
+ await ctx.db
351
+ .prepare("UPDATE content_revisions SET tag = 'foundry.put.unverified' WHERE id = ?")
352
+ .bind(revision.id)
353
+ .run();
354
+ }
355
+ }
356
+ /** True while the working copy is still exactly what Foundry's last v2 PUT wrote. */
357
+ async function lastFoundryWriteCurrent(ctx, row) {
358
+ const versions = await ctx.db
359
+ .prepare(`SELECT c.canonical_version AS current_version,
360
+ (SELECT json_extract(r.payload_json, '$.item.canonical_version')
361
+ FROM content_revisions r
362
+ WHERE r.content_id = c.id AND r.created_by = 'foundry' AND r.tag = ?
363
+ ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1) AS put_version
364
+ FROM content_items c
365
+ WHERE c.id = ? AND c.deleted_at IS NULL
366
+ LIMIT 1`)
367
+ .bind(`${FOUNDRY_PUT_TAG}${row.request_sha256}`, row.content_id)
368
+ .first();
369
+ return (versions !== null &&
370
+ versions.put_version !== null &&
371
+ versions.current_version === versions.put_version);
372
+ }
373
+ /** The v2 receipt: the four v1 fields plus the stored request hash. */
374
+ function v2Receipt(row) {
375
+ return { ...receipt(row), request_sha256: row.request_sha256 };
376
+ }
377
+ /** v2 PUTs may write a machine draft or edit a published item in place (a resync). */
378
+ const V2_WRITABLE = new Set(['draft', 'published']);
379
+ async function putV2(ctx, config, handlers, id, payload, bodyHash) {
380
+ const parsed = FoundryArticleV2Schema.safeParse(payload);
381
+ if (!parsed.success)
382
+ return json({ error: 'invalid_request', details: parsed.error.flatten() }, 400);
383
+ const body = parsed.data;
384
+ const prior = await findArticle(ctx, id);
385
+ if (prior?.request_sha256 === bodyHash) {
386
+ console.info('[cms] foundry article request deduplicated', {
387
+ externalId: id,
388
+ receiptId: prior.receipt_id,
389
+ });
390
+ return json(v2Receipt(prior));
391
+ }
392
+ let bound = prior
393
+ ? { id: prior.content_id, status: prior.status, type: prior.type, slug: prior.slug }
394
+ : await findContentBySource(ctx, id);
395
+ if (bound && bound.type !== 'article')
396
+ return json({ error: 'external_id_conflict' }, 409);
397
+ // Scheduled, review and archived items stay human-owned.
398
+ if (bound && !V2_WRITABLE.has(bound.status))
399
+ return approvalConflict(id, bound.status);
400
+ let adoption = null;
401
+ if (bound) {
402
+ const holder = await findCandidate(ctx, 'slug', body.slug);
403
+ if (holder && holder.id !== bound.id)
404
+ return slugConflict(id, body.slug, holder.id);
405
+ }
406
+ else {
407
+ const resolved = await resolveAdoption(ctx, config, id, body);
408
+ if ('error' in resolved)
409
+ return resolved.error;
410
+ if (resolved.candidate && resolved.matchedBy) {
411
+ const candidate = resolved.candidate;
412
+ if (!(await bindAdoption(ctx, config, id, candidate))) {
413
+ return json({
414
+ error: 'adoption_conflict',
415
+ external_id: id,
416
+ content_id: candidate.id,
417
+ reason: 'binding_raced',
418
+ }, 409);
419
+ }
420
+ adoption = {
421
+ content_id: candidate.id,
422
+ from_source_id: candidate.source_id,
423
+ matched_by: resolved.matchedBy,
424
+ };
425
+ console.info('[cms] foundry article adopted', { externalId: id, ...adoption });
426
+ if (!V2_WRITABLE.has(candidate.status)) {
427
+ return json({
428
+ error: 'published_requires_approval',
429
+ external_id: id,
430
+ state: candidate.status,
431
+ adoption,
432
+ }, 409);
433
+ }
434
+ bound = {
435
+ id: candidate.id,
436
+ status: candidate.status,
437
+ type: candidate.type,
438
+ slug: candidate.slug,
439
+ };
440
+ }
441
+ }
442
+ // The canonical version Foundry's write must produce is one over this.
443
+ const priorVersion = bound ? await canonicalVersion(ctx, bound.id) : null;
444
+ const heroImageId = body.hero_image_url
445
+ ? await heroAssetId(body.hero_image_url)
446
+ : (body.hero_image_key ?? undefined);
447
+ const nativeBody = {
448
+ title: body.title,
449
+ slug: body.slug,
450
+ ...(heroImageId ? { heroImageId } : {}),
451
+ ...(body.description !== undefined
452
+ ? { description: body.description, excerpt: body.description }
453
+ : {}),
454
+ ...(body.author !== undefined ? { byline: body.author } : {}),
455
+ ...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
456
+ ...(body.tags !== undefined ? { tags: body.tags } : {}),
457
+ ...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
458
+ content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
459
+ };
460
+ let nativeResponse;
461
+ if (bound) {
462
+ // Each write is a CAS on the status it was read in: a draft stays a
463
+ // draft write, and a published item is edited in place only while it is
464
+ // still published (its live revision changes only on POST /publish).
465
+ const writer = bound.status === 'published' ? handlers.published : handlers.draft;
466
+ nativeResponse = await mapUpdateConflict(await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })), id);
467
+ }
468
+ else {
469
+ try {
470
+ nativeResponse = await handlers.draft.create(nativeContext(ctx, 'POST', { ...nativeBody, type: 'article', sourceId: `${SOURCE_PREFIX}${id}` }, {}));
471
+ }
472
+ catch (error) {
473
+ const raced = await findContentBySource(ctx, id);
474
+ if (raced?.type !== 'article')
475
+ throw error;
476
+ nativeResponse = await mapUpdateConflict(await handlers.draft.update(nativeContext(ctx, 'PATCH', nativeBody, { id: raced.id })), id);
477
+ }
478
+ }
479
+ if (!nativeResponse.ok)
480
+ return nativeResponse;
481
+ if (body.hero_image_url && heroImageId) {
482
+ await persistHeroAsset(ctx, config, heroImageId, body.hero_image_url);
483
+ }
484
+ const written = await findContentBySource(ctx, id);
485
+ if (!written)
486
+ return json({ error: 'write_failed' }, 500);
487
+ // The native writer suffixes a taken slug; a v2 slug is verbatim or refused.
488
+ if (written.slug !== body.slug)
489
+ return slugConflict(id, body.slug, written.id);
490
+ // A draft carries the requested publication time for POST /publish to act on.
491
+ await ctx.db
492
+ .prepare(`UPDATE content_items SET publish_at = ?
493
+ WHERE id = ? AND status = 'draft' AND deleted_at IS NULL`)
494
+ .bind(body.publishAt ? Math.floor(Date.parse(body.publishAt) / 1_000) : null, written.id)
495
+ .run();
496
+ const item = await findContentBySource(ctx, id);
497
+ if (!item)
498
+ return json({ error: 'write_failed' }, 500);
499
+ await recordFoundryWrite(ctx, item.id, bodyHash, priorVersion);
500
+ const receiptId = crypto.randomUUID();
501
+ await upsertReceipt(ctx, {
502
+ externalId: id,
503
+ contentId: item.id,
504
+ receiptId,
505
+ requestHash: bodyHash,
506
+ });
507
+ console.info('[cms] foundry article upserted', { externalId: id, receiptId });
508
+ const result = v2Receipt({
509
+ external_id: id,
510
+ receipt_id: receiptId,
511
+ status: item.status,
512
+ slug: item.slug,
513
+ request_sha256: bodyHash,
514
+ });
515
+ return json(adoption ? { ...result, adoption } : result);
516
+ }
517
+ /** True when the live revision already carries the item's current canonical edit. */
518
+ async function publishedRevisionCurrent(ctx, contentId) {
519
+ const row = await ctx.db
520
+ .prepare(`SELECT c.canonical_version AS current_version,
521
+ json_extract(r.payload_json, '$.item.canonical_version') AS published_version
522
+ FROM content_items c
523
+ JOIN content_revisions r ON r.id = c.published_revision_id AND r.content_id = c.id
524
+ WHERE c.id = ? AND c.status = 'published' AND c.deleted_at IS NULL
525
+ LIMIT 1`)
526
+ .bind(contentId)
527
+ .first();
528
+ return (row !== null && row.current_version !== null && row.current_version === row.published_version);
529
+ }
530
+ async function publishV2(ctx, config, content, id, row) {
531
+ // Idempotent: a scheduled item belongs to the engine's scheduler, and a
532
+ // published item whose live revision is current has nothing to resync.
533
+ if (row.status === 'scheduled')
534
+ return json(v2Receipt(row));
535
+ if (row.status === 'published' && (await publishedRevisionCurrent(ctx, row.content_id))) {
536
+ return json(v2Receipt(row));
537
+ }
538
+ if (!V2_WRITABLE.has(row.status))
539
+ return approvalConflict(id, row.status);
540
+ // Publish, schedule and resync alike act on the working copy only while it is
541
+ // still exactly what Foundry's last v2 PUT wrote: a human edit since then (to
542
+ // a draft or a published item) is never machine-published or machine-scheduled.
543
+ if (!(await lastFoundryWriteCurrent(ctx, row))) {
544
+ return json({ error: 'human_edit_pending', external_id: id }, 409);
545
+ }
546
+ const item = await ctx.db
547
+ .prepare('SELECT publish_at, published_at FROM content_items WHERE id = ? LIMIT 1')
548
+ .bind(row.content_id)
549
+ .first();
550
+ if (!item)
551
+ return json({ error: 'not_found' }, 404);
552
+ const now = Math.floor(Date.now() / 1_000);
553
+ if (row.status === 'draft' && item.publish_at !== null && item.publish_at > now) {
554
+ // A scheduled item is published later by the site's scheduler, not here.
555
+ // Unless the publication declares that scheduler projects through
556
+ // publishOne with its live surfaces, it would publish with none: refuse
557
+ // at the request instead of leaving a silent gap.
558
+ if (config.foundryPublish?.scheduling !== 'publishOne') {
559
+ return json({
560
+ error: 'scheduling_requires_surface_scheduler',
561
+ external_id: id,
562
+ publish_at: new Date(item.publish_at * 1_000).toISOString(),
563
+ }, 409);
564
+ }
565
+ const scheduled = await content.action(nativeContext(ctx, 'POST', { publishAt: new Date(item.publish_at * 1_000).toISOString() }, { id: row.content_id }, '?action=schedule'));
566
+ if (!scheduled.ok)
567
+ return scheduled;
568
+ }
569
+ else {
570
+ // Fail closed before any write: a publish needs the publication's live surfaces.
571
+ const surfaces = await resolveLiveSurfaces(ctx, config);
572
+ if (!surfaces) {
573
+ return json({ error: 'GL_CMS_FOUNDRY_PUBLISH_UNCONFIGURED', external_id: id }, 503);
574
+ }
575
+ // A resync keeps the original publication time; a first publish takes the
576
+ // requested (past) time, else now.
577
+ const publishedAt = row.status === 'published' ? (item.published_at ?? now) : (item.publish_at ?? now);
578
+ // The native publish action, unchanged, with only its commit step swapped
579
+ // for publishOne + live surfaces: readiness, body guard, dek, tag check,
580
+ // activity log, takeaways, FAQ and the content.published webhook all run
581
+ // exactly as for an editor's publish.
582
+ const commit = async (_ctx, input) => {
583
+ const published = await publishOne(ctx.db, {
584
+ contentId: input.contentId,
585
+ now: input.now,
586
+ publishedAt,
587
+ createdBy: 'foundry',
588
+ surfaces,
589
+ ...(config.foundryPublish?.requiredSurfaceNames
590
+ ? { requiredSurfaceNames: config.foundryPublish.requiredSurfaceNames }
591
+ : {}),
592
+ packageVersion: config.foundryPublish?.packageVersion ?? null,
593
+ });
594
+ console.info('[cms] foundry article published', {
595
+ externalId: id,
596
+ attemptId: published.attemptId,
597
+ revisionId: published.revisionId,
598
+ surfaceStatus: published.surfaceStatus,
599
+ resync: row.status === 'published',
600
+ });
601
+ };
602
+ const publisher = createContentRoutes({ ...machineConfig(config), publishCommit: commit });
603
+ let response;
604
+ try {
605
+ response = await publisher.action(nativeContext(ctx, 'POST', undefined, { id: row.content_id }, '?action=publish'));
606
+ }
607
+ catch (error) {
608
+ return publishFailure(id, error);
609
+ }
610
+ if (!response.ok)
611
+ return response;
612
+ }
613
+ const after = await findArticle(ctx, id);
614
+ if (!after)
615
+ return json({ error: 'write_failed' }, 500);
616
+ const receiptId = crypto.randomUUID();
617
+ await upsertReceipt(ctx, {
618
+ externalId: id,
619
+ contentId: row.content_id,
620
+ receiptId,
621
+ requestHash: row.request_sha256,
622
+ });
623
+ return json(v2Receipt({ ...after, receipt_id: receiptId }));
624
+ }
161
625
  export function createFoundryPublishRoutes(config) {
162
626
  const content = createContentRoutes(machineConfig(config));
627
+ // A publication that declares a canonical host (statically, or on its
628
+ // opt-in) verifies host-bound only, with or without autoPublish; an opted-in
629
+ // one must declare it. Missing (when opted in), malformed or conflicting
630
+ // fails closed on every machine verb instead of falling back to the unbound
631
+ // form (the HMAC secret is shared by every site). Nothing declared and not
632
+ // opted in: the legacy form, byte-for-byte as before.
633
+ const declaration = declaredCanonicalHost(config);
634
+ const signingHost = declaration?.host ?? null;
635
+ const hostUnconfigured = declaration ? declaration.host === null : autoPublishEnabled(config);
636
+ if (hostUnconfigured) {
637
+ console.error('[cms] the Foundry canonicalHost is missing (autoPublish is on), invalid, or conflicting');
638
+ }
639
+ const verify = (ctx, body) => hostUnconfigured
640
+ ? Promise.resolve({
641
+ error: json({
642
+ error: 'GL_CMS_FOUNDRY_HOST_UNCONFIGURED',
643
+ detail: 'a declared canonicalHost must be valid and agree, and is required when autoPublish is on',
644
+ }, 503),
645
+ })
646
+ : authenticate(ctx, body, signingHost);
647
+ // Built only for an opted-in publication: the in-place writer for a published item.
648
+ const publishedWriter = autoPublishEnabled(config)
649
+ ? createContentRoutes({ ...machineConfig(config), updateStatusConstraint: 'published' })
650
+ : null;
163
651
  return {
164
652
  async put(ctx) {
165
653
  const id = externalId(ctx);
166
654
  if (!id)
167
655
  return json({ error: 'invalid_external_id' }, 400);
168
656
  const rawBody = await ctx.request.text();
169
- const auth = await authenticate(ctx, rawBody);
657
+ const auth = await verify(ctx, rawBody);
170
658
  if ('error' in auth)
171
659
  return auth.error;
172
660
  let payload;
@@ -176,6 +664,9 @@ export function createFoundryPublishRoutes(config) {
176
664
  catch {
177
665
  return json({ error: 'invalid_json' }, 400);
178
666
  }
667
+ if (autoPublishEnabled(config) && isV2Payload(payload)) {
668
+ return putV2(ctx, config, { draft: content, published: publishedWriter ?? content }, id, payload, auth.bodyHash);
669
+ }
179
670
  const parsed = PutSchema.safeParse(payload);
180
671
  if (!parsed.success)
181
672
  return json({ error: 'invalid_request', details: parsed.error.flatten() }, 400);
@@ -217,7 +708,7 @@ export function createFoundryPublishRoutes(config) {
217
708
  }
218
709
  catch (error) {
219
710
  const raced = await findContentBySource(ctx, id);
220
- if (!raced || raced.type !== 'article')
711
+ if (raced?.type !== 'article')
221
712
  throw error;
222
713
  nativeResponse = await mapUpdateConflict(await content.update(nativeContext(ctx, 'PATCH', body, { id: raced.id })), id);
223
714
  }
@@ -254,13 +745,15 @@ export function createFoundryPublishRoutes(config) {
254
745
  const id = externalId(ctx);
255
746
  if (!id)
256
747
  return json({ error: 'invalid_external_id' }, 400);
257
- const auth = await authenticate(ctx, '');
748
+ const auth = await verify(ctx, '');
258
749
  if ('error' in auth)
259
750
  return auth.error;
260
751
  const row = await findArticle(ctx, id);
261
752
  if (!row)
262
753
  return json({ error: 'not_found' }, 404);
263
754
  console.info('[cms] foundry article read', { externalId: id, receiptId: row.receipt_id });
755
+ if (autoPublishEnabled(config))
756
+ return json(v2Receipt(row));
264
757
  return json(receipt(row));
265
758
  },
266
759
  async unpublish(ctx) {
@@ -268,7 +761,7 @@ export function createFoundryPublishRoutes(config) {
268
761
  if (!id)
269
762
  return json({ error: 'invalid_external_id' }, 400);
270
763
  const rawBody = await ctx.request.text();
271
- const auth = await authenticate(ctx, rawBody);
764
+ const auth = await verify(ctx, rawBody);
272
765
  if ('error' in auth)
273
766
  return auth.error;
274
767
  const row = await findArticle(ctx, id);
@@ -290,6 +783,26 @@ export function createFoundryPublishRoutes(config) {
290
783
  console.info('[cms] foundry article unpublished', { externalId: id, receiptId });
291
784
  return json(receipt(result));
292
785
  },
786
+ async publish(ctx) {
787
+ const id = externalId(ctx);
788
+ if (!id)
789
+ return json({ error: 'invalid_external_id' }, 400);
790
+ const rawBody = await ctx.request.text();
791
+ const auth = await verify(ctx, rawBody);
792
+ if ('error' in auth)
793
+ return auth.error;
794
+ // POST /publish takes no body: anything sent is refused, never ignored.
795
+ if (rawBody.length > 0 || !/^0?$/.test(ctx.request.headers.get('Content-Length') ?? '')) {
796
+ return json({ error: 'invalid_request', detail: 'publish takes no request body' }, 400);
797
+ }
798
+ const row = await findArticle(ctx, id);
799
+ if (!row)
800
+ return json({ error: 'not_found' }, 404);
801
+ // Without the publication's opt-in the machine route stays draft-only.
802
+ if (!autoPublishEnabled(config))
803
+ return approvalConflict(id, row.status);
804
+ return publishV2(ctx, config, content, id, row);
805
+ },
293
806
  };
294
807
  }
295
808
  //# sourceMappingURL=foundry-publish.js.map