@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.
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/foundry-dispatch.d.ts +1 -0
  6. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  7. package/dist/engine/foundry-dispatch.js +1 -1
  8. package/dist/engine/foundry-dispatch.js.map +1 -1
  9. package/dist/engine/fronts-publish-intent.d.ts +1 -1
  10. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  11. package/dist/engine/fronts-publish-intent.js +12 -1
  12. package/dist/engine/fronts-publish-intent.js.map +1 -1
  13. package/dist/engine/index.d.ts +1 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +2 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  18. package/dist/engine/publisher.d.ts +16 -1
  19. package/dist/engine/publisher.d.ts.map +1 -1
  20. package/dist/engine/publisher.js +55 -4
  21. package/dist/engine/publisher.js.map +1 -1
  22. package/dist/engine/tags.d.ts.map +1 -1
  23. package/dist/engine/tags.js +18 -1
  24. package/dist/engine/tags.js.map +1 -1
  25. package/dist/engine/topics.d.ts.map +1 -1
  26. package/dist/engine/topics.js +4 -1
  27. package/dist/engine/topics.js.map +1 -1
  28. package/dist/engine/video-source.d.ts +21 -0
  29. package/dist/engine/video-source.d.ts.map +1 -1
  30. package/dist/engine/video-source.js +40 -0
  31. package/dist/engine/video-source.js.map +1 -1
  32. package/dist/foundry-emit.d.ts +146 -0
  33. package/dist/foundry-emit.d.ts.map +1 -0
  34. package/dist/foundry-emit.js +189 -0
  35. package/dist/foundry-emit.js.map +1 -0
  36. package/dist/integration/index.d.ts.map +1 -1
  37. package/dist/integration/index.js +9 -0
  38. package/dist/integration/index.js.map +1 -1
  39. package/dist/integration/options.d.ts +24 -0
  40. package/dist/integration/options.d.ts.map +1 -1
  41. package/dist/integration/options.js +22 -0
  42. package/dist/integration/options.js.map +1 -1
  43. package/dist/providers/null.d.ts.map +1 -1
  44. package/dist/providers/null.js +6 -0
  45. package/dist/providers/null.js.map +1 -1
  46. package/dist/providers/types.d.ts +56 -0
  47. package/dist/providers/types.d.ts.map +1 -1
  48. package/dist/providers/types.js.map +1 -1
  49. package/dist/routes/ai.d.ts.map +1 -1
  50. package/dist/routes/ai.js +21 -13
  51. package/dist/routes/ai.js.map +1 -1
  52. package/dist/routes/analytics.d.ts +1 -0
  53. package/dist/routes/analytics.d.ts.map +1 -1
  54. package/dist/routes/analytics.js +20 -0
  55. package/dist/routes/analytics.js.map +1 -1
  56. package/dist/routes/content.d.ts +27 -0
  57. package/dist/routes/content.d.ts.map +1 -1
  58. package/dist/routes/content.js +58 -12
  59. package/dist/routes/content.js.map +1 -1
  60. package/dist/routes/foundry-publish.d.ts +50 -0
  61. package/dist/routes/foundry-publish.d.ts.map +1 -1
  62. package/dist/routes/foundry-publish.js +540 -27
  63. package/dist/routes/foundry-publish.js.map +1 -1
  64. package/dist/routes/machine-context.d.ts +6 -0
  65. package/dist/routes/machine-context.d.ts.map +1 -0
  66. package/dist/routes/machine-context.js +20 -0
  67. package/dist/routes/machine-context.js.map +1 -0
  68. package/dist/routes/surveys.d.ts +14 -1
  69. package/dist/routes/surveys.d.ts.map +1 -1
  70. package/dist/routes/surveys.js +515 -10
  71. package/dist/routes/surveys.js.map +1 -1
  72. package/dist/schema/insights-ingest.d.ts +8 -8
  73. package/dist/schema/migrations.d.ts.map +1 -1
  74. package/dist/schema/migrations.js +15 -0
  75. package/dist/schema/migrations.js.map +1 -1
  76. package/dist/schema/tables.d.ts +1 -1
  77. package/dist/schema/tables.d.ts.map +1 -1
  78. package/dist/schema/tables.js +2 -0
  79. package/dist/schema/tables.js.map +1 -1
  80. package/dist/surveys/access.d.ts +1 -0
  81. package/dist/surveys/access.d.ts.map +1 -1
  82. package/dist/surveys/access.js +13 -4
  83. package/dist/surveys/access.js.map +1 -1
  84. package/dist/surveys/auto-label.d.ts +100 -0
  85. package/dist/surveys/auto-label.d.ts.map +1 -0
  86. package/dist/surveys/auto-label.js +325 -0
  87. package/dist/surveys/auto-label.js.map +1 -0
  88. package/dist/surveys/schema.d.ts +38 -12
  89. package/dist/surveys/schema.d.ts.map +1 -1
  90. package/dist/surveys/schema.js +18 -1
  91. package/dist/surveys/schema.js.map +1 -1
  92. package/dist/ui/api/_content-config.d.ts.map +1 -1
  93. package/dist/ui/api/_content-config.js +4 -0
  94. package/dist/ui/api/_content-config.js.map +1 -1
  95. package/dist/ui/api/analytics.d.ts.map +1 -1
  96. package/dist/ui/api/analytics.js +5 -2
  97. package/dist/ui/api/analytics.js.map +1 -1
  98. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  99. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  100. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  101. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  102. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  103. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  104. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  105. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  106. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  107. package/dist/ui/api/surveys/[surveyId].js +28 -1
  108. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  109. package/dist/ui/commands.d.ts.map +1 -1
  110. package/dist/ui/commands.js +2 -0
  111. package/dist/ui/commands.js.map +1 -1
  112. package/dist/ui/hash-router.d.ts +1 -1
  113. package/dist/ui/hash-router.d.ts.map +1 -1
  114. package/dist/ui/hash-router.js +1 -0
  115. package/dist/ui/hash-router.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/foundry-dispatch.ts +1 -1
  147. package/src/engine/fronts-publish-intent.ts +23 -1
  148. package/src/engine/index.ts +5 -0
  149. package/src/engine/publisher.ts +80 -4
  150. package/src/engine/tags.ts +21 -1
  151. package/src/engine/topics.ts +4 -1
  152. package/src/engine/video-source.ts +44 -0
  153. package/src/foundry-emit.ts +255 -0
  154. package/src/integration/index.ts +9 -0
  155. package/src/integration/options.ts +23 -0
  156. package/src/providers/null.ts +6 -0
  157. package/src/providers/types.ts +52 -0
  158. package/src/routes/ai.ts +29 -17
  159. package/src/routes/analytics.ts +21 -0
  160. package/src/routes/content.ts +81 -15
  161. package/src/routes/foundry-publish.ts +746 -29
  162. package/src/routes/machine-context.ts +24 -0
  163. package/src/routes/surveys.ts +587 -12
  164. package/src/schema/migrations.ts +15 -0
  165. package/src/schema/tables.ts +2 -0
  166. package/src/surveys/access.ts +14 -4
  167. package/src/surveys/auto-label.ts +420 -0
  168. package/src/surveys/schema.ts +25 -1
  169. package/src/ui/api/_content-config.ts +6 -0
  170. package/src/ui/api/analytics.ts +5 -2
  171. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  172. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  173. package/src/ui/api/surveys/[surveyId].ts +37 -3
  174. package/src/ui/commands.ts +2 -0
  175. package/src/ui/hash-router.ts +1 -0
  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
@@ -64,6 +64,214 @@ GET returns HTTP 404 with `{ "error": "not_found" }` when the external id is abs
64
64
 
65
65
  POST has no request body. It moves an item back to `draft` through the native CMS lifecycle handler and returns the same four-field receipt shape. Repeating the request while already draft is idempotent.
66
66
 
67
+ ## v2 body, `POST /publish` and adoption (opt-in, per publication)
68
+
69
+ Draft-only is the default and stays byte-identical to the v1 contract above. A
70
+ publication opts in through the route's config, which the Astro adapter reads
71
+ from `locals.cmsHooks.foundryPublish`:
72
+
73
+ ```ts
74
+ locals.cmsHooks = {
75
+ ...locals.cmsHooks,
76
+ foundryPublish: {
77
+ autoPublish: true, // only the literal `true` opts in
78
+ canonicalHost: 'warfronts.pub', // REQUIRED when opted in: lowercase, no scheme, no port
79
+ surfaces: (ctx) => [sitemapSurface, feedSurface, markdownTwinSurface], // the LIVE surfaces
80
+ requiredSurfaceNames: ['seo-discovery'], // optional, forwarded to publishOne
81
+ bridgeSourceNamespace: 'warfronts', // the archive bridge's source_id namespace (adoption)
82
+ scheduling: 'publishOne', // only if the site's scheduler projects through publishOne
83
+ },
84
+ }
85
+ ```
86
+
87
+ With the flag off (omitted, `false`, or anything but `true`):
88
+
89
+ - every existing request takes the v1 path, byte-for-byte;
90
+ - a body carrying `schemaVersion: 2` is refused as today (`400 invalid_request`);
91
+ - `POST .../publish` returns `409 published_requires_approval` (404 for an unknown id).
92
+
93
+ With the flag on, v1 bodies still take the v1 path unchanged. Only a body
94
+ declaring `"schemaVersion": 2` takes the v2 path.
95
+
96
+ ### Host-bound signatures on opted-in publications
97
+
98
+ The Foundry HMAC secret is one value shared by every site. An opted-in
99
+ publication therefore verifies every `/api/cms/foundry/*` verb (PUT, GET,
100
+ publish, unpublish) against a host-bound string, newline-joined, with no
101
+ trailing newline:
102
+
103
+ ```text
104
+ foundry-cms-v2
105
+ METHOD
106
+ HOST
107
+ PATH
108
+ TIMESTAMP
109
+ sha256hex(raw_body)
110
+ ```
111
+
112
+ - `HOST` is the configured `foundryPublish.canonicalHost`, never the request's
113
+ Host header. A request signed for another site, the legacy dot form, or a
114
+ dot-joined host-bound string is `401`.
115
+ - An opted-in publication without a valid `canonicalHost` answers
116
+ `503 GL_CMS_FOUNDRY_HOST_UNCONFIGURED` on every verb; it never falls back to
117
+ the unbound form.
118
+ - Publications that do not opt in keep the legacy `method.path.timestamp.sha256`
119
+ string described above, byte-for-byte.
120
+ - Foundry's signer is `SignPublishRequestV2` (fulcrum-labs/foundry
121
+ `articlechain`). This route verifies with `@growth-labs/foundry-signing`, the
122
+ one shared implementation, whose golden vectors (`/fixtures`) the route tests
123
+ import.
124
+
125
+ ### v2 PUT
126
+
127
+ ```json
128
+ {
129
+ "schemaVersion": 2,
130
+ "slug": "exact-canonical-slug",
131
+ "title": "Article title",
132
+ "body_html": "<p>Complete article HTML.</p>",
133
+ "hero_image_key": "articles/<id>/hero.jpg",
134
+ "publishAt": "2026-10-01T09:00:00Z",
135
+ "author": "Byline",
136
+ "desk": "analysis",
137
+ "tags": ["drones"],
138
+ "description": "The dek.",
139
+ "visibility": "free",
140
+ "adoptSourceId": "warfronts:articles/military/<file>.md"
141
+ }
142
+ ```
143
+
144
+ The schema is strict and exported as `FoundryArticleV2Schema` from
145
+ `@growth-labs/cms/foundry-emit`.
146
+
147
+ - `slug`, `title` and `body_html` are required.
148
+ - `slug` is used verbatim and must already be canonical (`slugify(slug) === slug`,
149
+ 3–512 characters). It is never derived and never suffixed: a slug held by
150
+ another item is `409 slug_conflict`.
151
+ - `hero_image_key` (a relative object key, stored as `hero_image_id` exactly as the
152
+ bridge import stores it) and `hero_image_url` (https) are mutually exclusive.
153
+ - `description` fills `description` and `excerpt`, `author` fills `byline`, and
154
+ `desk` fills the primary category.
155
+ - `publishAt` is carried on the draft (`publish_at`) for `POST /publish` to act on.
156
+
157
+ A v2 PUT never publishes. It writes a draft, or, on an opted-in publication, edits
158
+ a **published** item in place. That write is a CAS on `status = 'published'`, and
159
+ the live revision does not move until `POST /publish` resyncs it. Scheduled,
160
+ review and archived items stay human-owned: a changed PUT is
161
+ `409 published_requires_approval`.
162
+
163
+ Every v2 PUT, publish and GET response is the four-field receipt plus
164
+ `request_sha256`: the lowercase hex SHA-256 of the exact bytes of the last
165
+ accepted v2 PUT body, the value the route stores. An identical repeat PUT returns
166
+ the existing receipt.
167
+
168
+ ### `POST /api/cms/foundry/articles/:external_id/publish`
169
+
170
+ HMAC-signed like the others, with an empty body. It returns the receipt (plus
171
+ `request_sha256`).
172
+
173
+ - **Draft** with `publish_at` in the future: scheduled through the native schedule
174
+ action (`state: "scheduled"`), but only when the publication declares
175
+ `foundryPublish.scheduling: 'publishOne'`, meaning its scheduler publishes
176
+ scheduled items through `publishOne` with the live surfaces. Otherwise the
177
+ request is refused with `409 scheduling_requires_surface_scheduler`: the legacy
178
+ scheduled sweep (`engine/cron.ts` `runScheduledPublish`) publishes with no
179
+ surfaces, and that gap must surface at the request, not after the fact.
180
+ - **Draft**, otherwise: published through `publishOne` with the publication's live
181
+ surfaces, so the `.md` twin, sitemap and feed are projected. The publication
182
+ time is the draft's `publishAt`, else now.
183
+ - **Every** publish, schedule or resync acts on the working copy only while it is
184
+ still exactly what Foundry's last v2 PUT wrote; this applies to drafts and to
185
+ published items alike.
186
+ - **Published** item whose working copy changed since its live revision: re-runs
187
+ `publishOne` (the resync), keeping the original publication time. The guard:
188
+ every v2 PUT records a History revision (`created_by: foundry`, tag
189
+ `foundry.put:<request_sha256>`), and the item's `canonical_version` must still
190
+ equal that revision's. A human edit since then is `409 human_edit_pending`,
191
+ with nothing written, until Foundry sends a new PUT or a human publishes.
192
+ Human-initiated writes outside the editor form (editor-triggered AI meta
193
+ description, dek, takeaways and OG image, the bulk topic op, and a topic rename)
194
+ advance `canonical_version` as an editor save does. The automatic enrichment a
195
+ publish triggers (the pre-commit dek, takeaways, FAQ and host enrichment) does
196
+ not. A person pulling an item (native unpublish, unschedule, or set-status to
197
+ draft or review) is a canonical edit too, so Foundry never republishes an
198
+ article a person pulled until it sends a new PUT. Foundry's own `/unpublish`
199
+ is not, so Foundry can unpublish and republish its own item. "Foundry's own" is
200
+ decided by an internal marker the HMAC-verified route sets on the contexts it
201
+ builds, never by a subject or user id. Tag rename and delete are canonical
202
+ edits to every tagged item too.
203
+ - `POST /publish` takes no body: any body bytes, or a non-zero `Content-Length`,
204
+ is `400 invalid_request`.
205
+ - Idempotent: a published item whose live revision is current, or a scheduled
206
+ item, returns its existing receipt.
207
+ - The route never passes `allowNoSurfaces`. Absent, empty or failing surfaces
208
+ refuse with `503 GL_CMS_FOUNDRY_PUBLISH_UNCONFIGURED` before anything is written.
209
+ - The publish runs the **native publish action itself**. Only its commit step is
210
+ swapped, from `publishContent` + `createRevision` to `publishOne` with the live
211
+ surfaces, through the internal `publishCommit` seam. So the readiness hook, the
212
+ body guard, dek generation (or the dek-failure notification), the tag check, the
213
+ activity log, takeaways and FAQ dispatch (first publish only), and the
214
+ `content.published` webhook run exactly as for an editor's publish, and its
215
+ refusals keep the native shapes.
216
+ - An exception thrown inside the publication is `502 publish_failed` (or `422
217
+ publish_validation_failed` from the kernel's own guard). It leaves the item and
218
+ its receipt unchanged, so a retry re-runs it.
219
+ - `unpublish` remains the undo.
220
+
221
+ ### One-time adoption of bridge-imported items
222
+
223
+ When no item is bound to `foundry:<external_id>` yet, a v2 PUT first looks for an
224
+ existing item:
225
+
226
+ - by exact `source_id` when the body carries `adoptSourceId`;
227
+ - otherwise by the verbatim slug.
228
+
229
+ Only a bridge-imported article that no external id owns can be adopted, and
230
+ only under the publication's declared bridge namespace
231
+ (`foundryPublish.bridgeSourceNamespace`, so `source_id` = `<namespace>:<relpath>`,
232
+ as a channel repo's `scripts/archive-import/compiler.mjs` `buildSourceId` writes
233
+ it). Any other source is `409 adoption_source_not_bridge`; with no namespace
234
+ declared, nothing is adoptable. Exactly one match is bound to `foundry:<external_id>` in one
235
+ D1 transaction together with a `foundry.adopted` row in `cms_activity_log`, and
236
+ the receipt carries an `adoption` object (`content_id`, `from_source_id`,
237
+ `matched_by`). A draft or published match is then written as above. A scheduled,
238
+ review or archived match is bound and answered with
239
+ `409 published_requires_approval` plus the `adoption` object.
240
+
241
+ | Response | When |
242
+ | --- | --- |
243
+ | `409 adoption_ambiguous` | `adoptSourceId` and the slug name two different items |
244
+ | `409 adoption_conflict` | the match is deleted, not an article, already bound to another external id, not bridge-imported, or the bind lost a race |
245
+ | `409 slug_conflict` | the slug is held by an item the request did not identify |
246
+
247
+ ### content-emit.json (`foundry-emit` bin)
248
+
249
+ `foundry-emit --app <id> --source-sha <sha> --out <file> [--in <archive.json>]`
250
+ reads an app compiler's ContentArchiveInput (`{ items }`, or compileArchive's
251
+ `{ archive, receipt }`) from `--in` or stdin. It writes:
252
+
253
+ ```json
254
+ { "schemaVersion": 1, "app": "warfronts", "sourceSha": "<sha>",
255
+ "items": [
256
+ { "path": "articles/x.md", "videoId": "abc", "retire": false,
257
+ "requestSha256": "<hex>", "request": "{\"schemaVersion\":2,...}" },
258
+ { "path": "articles/old.md", "videoId": "def", "retire": true }
259
+ ] }
260
+ ```
261
+
262
+ - It covers the whole archive; any item that cannot be emitted fails the run.
263
+ - `retire` is the compiler's carry of frontmatter `status: archived`. A retire item
264
+ omits `request` and `requestSha256`.
265
+ - `request` is a JSON **string**, the exact PUT body, so re-indenting the envelope
266
+ never changes its bytes.
267
+ - `requestSha256` is the SHA-256 of its UTF-8 bytes. When foundryd PUTs exactly
268
+ those bytes, the route stores and echoes the same value as `request_sha256`.
269
+
270
+ The mapper is exported as `toFoundryArticleV2(item)`, and the envelope builder as
271
+ `buildContentEmit(archive, { app, sourceSha })`.
272
+
273
+ No migration is needed for v2.
274
+
67
275
  ## Cloudflare binding
68
276
 
69
277
  ```toml
@@ -0,0 +1,15 @@
1
+ -- Owner-side classification of written survey answers (2026-10-02). Only the
2
+ -- CMS owner ever sees an answer that is not labeled 'general': technical and
3
+ -- unlabeled comments are withheld from every other role by the survey routes.
4
+ -- No FK to a survey table: surveys live in the reporting upstream, so the key
5
+ -- references provider-issued ids only.
6
+ CREATE TABLE survey_answer_labels (
7
+ survey_id TEXT NOT NULL,
8
+ response_id TEXT NOT NULL,
9
+ question_id TEXT NOT NULL,
10
+ label TEXT NOT NULL CHECK (label IN ('technical','general')),
11
+ source TEXT NOT NULL CHECK (source IN ('owner','auto','import')),
12
+ labeled_by TEXT,
13
+ labeled_at INTEGER NOT NULL,
14
+ PRIMARY KEY (survey_id, response_id, question_id)
15
+ );
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.8.20",
3
+ "version": "0.8.24",
4
4
  "type": "module",
5
5
  "bin": {
6
- "growth-labs-cms-migrations": "./dist/cli/migrations.js"
6
+ "growth-labs-cms-migrations": "./dist/cli/migrations.js",
7
+ "foundry-emit": "./dist/cli/foundry-emit.js"
7
8
  },
8
9
  "types": "./dist/index.d.ts",
9
10
  "exports": {
@@ -31,6 +32,10 @@
31
32
  "types": "./dist/integration/index.d.ts",
32
33
  "import": "./dist/integration/index.js"
33
34
  },
35
+ "./foundry-emit": {
36
+ "types": "./dist/foundry-emit.d.ts",
37
+ "import": "./dist/foundry-emit.js"
38
+ },
34
39
  "./migration-vendor": {
35
40
  "types": "./dist/migration-vendor.d.ts",
36
41
  "import": "./dist/migration-vendor.js"
@@ -128,7 +133,8 @@
128
133
  "@tiptap/suggestion": "3.30.5",
129
134
  "@tiptap/y-tiptap": "^3.0.9",
130
135
  "yjs": "^13.6.32",
131
- "zod": "^3.24.0"
136
+ "zod": "^3.24.0",
137
+ "@growth-labs/foundry-signing": "^0.1.0"
132
138
  },
133
139
  "devDependencies": {
134
140
  "@puckeditor/core": "0.23.0",
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // foundry-emit --app <id> --source-sha <sha> --out <file> [--in <archive.json>]
3
+ //
4
+ // Reads an app compiler's ContentArchiveInput (`{ items }`, or compileArchive's
5
+ // `{ archive, receipt }`) from --in or stdin and writes content-emit.json: the
6
+ // whole archive as the exact v2 PUT bodies foundryd sends (see
7
+ // buildContentEmit in ../foundry-emit.ts).
8
+ import { readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { pathToFileURL } from 'node:url'
10
+ import type { ContentArchiveInput } from '../engine/publication.js'
11
+ import { buildContentEmit, type ContentEmit } from '../foundry-emit.js'
12
+
13
+ const USAGE = 'Usage: foundry-emit --app <id> --source-sha <sha> --out <file> [--in <archive.json>]'
14
+
15
+ interface EmitArgs {
16
+ app: string
17
+ sourceSha: string
18
+ out: string
19
+ in?: string
20
+ }
21
+
22
+ export function parseEmitArgs(argv: string[]): EmitArgs {
23
+ const values: Record<string, string> = {}
24
+ for (let index = 0; index < argv.length; index += 2) {
25
+ const flag = argv[index]
26
+ const value = argv[index + 1]
27
+ if (!['--app', '--source-sha', '--out', '--in'].includes(flag ?? '')) {
28
+ throw new Error(`unknown argument ${flag}\n${USAGE}`)
29
+ }
30
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value\n${USAGE}`)
31
+ values[(flag as string).slice(2)] = value
32
+ }
33
+ for (const required of ['app', 'source-sha', 'out']) {
34
+ if (!values[required]) throw new Error(`--${required} is required\n${USAGE}`)
35
+ }
36
+ return {
37
+ app: values.app as string,
38
+ sourceSha: values['source-sha'] as string,
39
+ out: values.out as string,
40
+ ...(values.in ? { in: values.in } : {}),
41
+ }
42
+ }
43
+
44
+ function archiveFrom(raw: string): ContentArchiveInput {
45
+ const parsed = JSON.parse(raw) as { items?: unknown; archive?: { items?: unknown } }
46
+ if (Array.isArray(parsed.items)) return parsed as ContentArchiveInput
47
+ if (parsed.archive && Array.isArray(parsed.archive.items)) {
48
+ return parsed.archive as ContentArchiveInput
49
+ }
50
+ throw new Error('input is not a ContentArchiveInput: no items array')
51
+ }
52
+
53
+ export async function runFoundryEmit(
54
+ argv: string[],
55
+ io: { readStdin: () => string } = { readStdin: () => readFileSync(0, 'utf8') },
56
+ ): Promise<ContentEmit> {
57
+ const args = parseEmitArgs(argv)
58
+ const raw = args.in ? readFileSync(args.in, 'utf8') : io.readStdin()
59
+ const emit = await buildContentEmit(archiveFrom(raw), {
60
+ app: args.app,
61
+ sourceSha: args.sourceSha,
62
+ })
63
+ // Written whole or not at all: a reader never sees a truncated emit.
64
+ const temporary = `${args.out}.tmp-${process.pid}`
65
+ writeFileSync(temporary, `${JSON.stringify(emit, null, 2)}\n`)
66
+ renameSync(temporary, args.out)
67
+ return emit
68
+ }
69
+
70
+ // An installed bin runs through a node_modules/.bin symlink, so compare real paths.
71
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
72
+ runFoundryEmit(process.argv.slice(2))
73
+ .then((emit) => {
74
+ const retired = emit.items.filter((item) => item.retire).length
75
+ console.log(
76
+ `foundry-emit: wrote ${emit.items.length} items (${retired} retire) for ${emit.app}@${emit.sourceSha}`,
77
+ )
78
+ })
79
+ .catch((error: unknown) => {
80
+ process.stderr.write(
81
+ `foundry-emit: ${error instanceof Error ? error.message : String(error)}\n`,
82
+ )
83
+ process.exitCode = 1
84
+ })
85
+ }
@@ -65,7 +65,7 @@ export interface CallbackInput {
65
65
  excerpt?: string | null
66
66
  }
67
67
 
68
- async function sha256Hex(value: string): Promise<string> {
68
+ export async function sha256Hex(value: string): Promise<string> {
69
69
  const bytes = new TextEncoder().encode(value)
70
70
  const digest = await crypto.subtle.digest('SHA-256', bytes)
71
71
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
@@ -47,7 +47,11 @@ import {
47
47
  podcastPublishBlocker,
48
48
  validPodcastSourceUrl,
49
49
  } from './podcast-source.js'
50
- import { frontsVideoSourceGeneration, videoSourceIngestableOverHttps } from './video-source.js'
50
+ import {
51
+ frontsVideoSourceGeneration,
52
+ videoSourceIngestableOverHttps,
53
+ videoSourceUrlIngestableOverHttps,
54
+ } from './video-source.js'
51
55
 
52
56
  /** The CMS's own name for the record this intent describes. */
53
57
  export const FRONTS_PUBLISH_INTENT_SOURCE = 'masthead'
@@ -71,6 +75,7 @@ export type FrontsPublishIntentErrorCode =
71
75
  | 'missing_article_body'
72
76
  | 'video_media_not_ready'
73
77
  | 'video_source_duration_unavailable'
78
+ | 'video_source_not_ingestable'
74
79
  | 'missing_podcast_audio'
75
80
  | PodcastPublishBlockerCode
76
81
 
@@ -779,6 +784,23 @@ async function videoSourceRef(
779
784
  !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlation)
780
785
  )
781
786
  return null
787
+ // A URL with a query, fragment, userinfo or non-https scheme passes
788
+ // validPodcastSourceUrl above but is refused by Foundry's media probe
789
+ // ("artifact ingest probe source URL is invalid"), which fails without
790
+ // telling the CMS — the HomeFronts 2026-10-01T18:05Z incident, where the
791
+ // video sat scheduled-but-never-prepared with no incomplete entry. Raise
792
+ // it loudly here instead of handing the URL to Foundry; consumers catch
793
+ // this per item, so the feed's other items are unaffected.
794
+ if (
795
+ found.processing_source_kind === 'http_url' &&
796
+ !videoSourceUrlIngestableOverHttps(found.processing_source_url)
797
+ ) {
798
+ throw new FrontsPublishIntentError(
799
+ 'video_source_not_ingestable',
800
+ row.id,
801
+ 'Video source URL is not a plain HTTPS URL Foundry can ingest (no query string or fragment); re-capture the source with its direct /download link.',
802
+ )
803
+ }
782
804
  if (
783
805
  typeof found.duration_seconds !== 'number' ||
784
806
  !Number.isFinite(found.duration_seconds) ||
@@ -213,6 +213,11 @@ export {
213
213
  isRetiredItem,
214
214
  payloadDrift,
215
215
  } from './publication-continuity.js'
216
+ // Trusted media producers use the same capture proof as the CMS dispatch path.
217
+ export {
218
+ MEDIA_PUBLICATION_ITEM_FIELDS,
219
+ mediaPublicationCaptureGuard,
220
+ } from './publication-current-guard.js'
216
221
  export {
217
222
  CanonicalJsonError,
218
223
  canonicalJson,
@@ -30,6 +30,7 @@ import {
30
30
  validateDeclaredTagList,
31
31
  validateStoredTaxonomyRows,
32
32
  } from './taxonomy.js'
33
+ import { VideoSourceNotIngestableError, videoSourceUrlIngestableOverHttps } from './video-source.js'
33
34
 
34
35
  export {
35
36
  MAX_ITEM_TAG_COUNT,
@@ -625,6 +626,20 @@ export async function createContent(
625
626
  db: D1Database,
626
627
  input: CreateContentInput,
627
628
  ): Promise<{ id: string; slug: string }> {
629
+ // Refuse before the provider probe or any row write: an http_url source
630
+ // with a query, fragment, userinfo or non-https scheme can never pass
631
+ // Foundry's media probe, so storing it strands a scheduled video (the
632
+ // HomeFronts 2026-10-01T18:05Z incident). Dropbox links legitimately
633
+ // carry `?dl=1` and stay out of this check.
634
+ if (
635
+ input.type === 'video' &&
636
+ input.content.processingSourceUrl !== undefined &&
637
+ input.content.processingSourceUrl !== null &&
638
+ (input.content.processingSourceKind ?? 'http_url') === 'http_url' &&
639
+ !videoSourceUrlIngestableOverHttps(input.content.processingSourceUrl)
640
+ ) {
641
+ throw new VideoSourceNotIngestableError()
642
+ }
628
643
  const resolvedMediaDuration =
629
644
  input.type === 'video'
630
645
  ? await resolveMediaDuration(
@@ -775,6 +790,21 @@ export async function getContentItem(
775
790
  .first<ContentItemRecord>()
776
791
  }
777
792
 
793
+ /**
794
+ * A human-initiated canonical change written outside updateContentItem (an
795
+ * editor-triggered AI write-back to a field updateContentItem does not carry):
796
+ * advances canonical_version exactly as updateContentItem's own UPDATE does.
797
+ * canonical_version is how the Foundry machine route knows the working copy is
798
+ * no longer only what its last PUT wrote (M3-18 resync guard).
799
+ */
800
+ export function canonicalEditStatement(db: D1Database, contentId: string) {
801
+ return db
802
+ .prepare(
803
+ 'UPDATE content_items SET canonical_version = canonical_version + 1, updated_at = unixepoch() WHERE id = ?',
804
+ )
805
+ .bind(contentId)
806
+ }
807
+
778
808
  export async function updateContentItem(
779
809
  db: D1Database,
780
810
  id: string,
@@ -823,6 +853,16 @@ export async function updateContentItem(
823
853
  const sourceKind = hasOwn(videoContent, 'processingSourceKind')
824
854
  ? (videoContent.processingSourceKind ?? null)
825
855
  : current.processing_source_kind
856
+ // Same refusal as createContent, against the merged (input-or-stored)
857
+ // source: a row that already holds a query-bearing link is corrected
858
+ // at the next save, while the editor is still there to re-capture it.
859
+ if (
860
+ sourceUrl !== null &&
861
+ (sourceKind ?? 'http_url') === 'http_url' &&
862
+ !videoSourceUrlIngestableOverHttps(sourceUrl)
863
+ ) {
864
+ throw new VideoSourceNotIngestableError()
865
+ }
826
866
  const sourceChanged =
827
867
  sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
828
868
  const durationProvided = hasOwn(videoContent, 'durationSeconds')
@@ -997,8 +1037,8 @@ export async function updateContentItem(
997
1037
  : existing.metadata_json
998
1038
 
999
1039
  if (options.requiredStatus) {
1000
- if (input.tags !== undefined || input.relations !== undefined) {
1001
- throw new TypeError('Status-constrained updates do not support tags or relations')
1040
+ if (input.relations !== undefined) {
1041
+ throw new TypeError('Status-constrained updates do not support relations')
1002
1042
  }
1003
1043
  if (!isBodyBackedContentType(existing.type)) {
1004
1044
  throw new TypeError('Status-constrained updates require body-backed content')
@@ -1131,6 +1171,31 @@ export async function updateContentItem(
1131
1171
  )
1132
1172
  }
1133
1173
 
1174
+ if (tagPairs) {
1175
+ // Tag links join the same transaction under the same status predicate,
1176
+ // so a refused (non-matching status) update leaves the links untouched.
1177
+ // Tag dictionary rows are shared and idempotent, so they may be ensured
1178
+ // up front even when the guarded write below is refused.
1179
+ const tagRows = tagPairs.length > 0 ? await ensureTags(db, tagPairs) : []
1180
+ const statusGuard = `EXISTS (SELECT 1 FROM content_items
1181
+ WHERE id = ? AND status = ? AND deleted_at IS NULL)`
1182
+ statements.push(
1183
+ db
1184
+ .prepare(`DELETE FROM content_tag_links WHERE content_id = ? AND ${statusGuard}`)
1185
+ .bind(id, id, options.requiredStatus),
1186
+ )
1187
+ for (const tag of tagRows) {
1188
+ statements.push(
1189
+ db
1190
+ .prepare(
1191
+ `INSERT INTO content_tag_links (content_id, tag_id, label)
1192
+ SELECT ?, ?, ? WHERE ${statusGuard}`,
1193
+ )
1194
+ .bind(id, tag.id, tag.label, id, options.requiredStatus),
1195
+ )
1196
+ }
1197
+ }
1198
+
1134
1199
  const statusResultIndex = statements.length
1135
1200
  statements.push(
1136
1201
  db
@@ -1677,14 +1742,25 @@ export async function claimScheduledForPublish(
1677
1742
  return changes > 0
1678
1743
  }
1679
1744
 
1680
- export async function unpublishContent(db: D1Database, id: string): Promise<void> {
1745
+ export async function unpublishContent(
1746
+ db: D1Database,
1747
+ id: string,
1748
+ options: {
1749
+ /**
1750
+ * A person pulled the item: advance canonical_version in the same UPDATE,
1751
+ * so a machine publisher can never put it back without a new write of its
1752
+ * own (the Foundry machine route's human-edit guard, M3-18).
1753
+ */
1754
+ canonicalEdit?: boolean
1755
+ } = {},
1756
+ ): Promise<void> {
1681
1757
  await db
1682
1758
  .prepare(
1683
1759
  `UPDATE content_items
1684
1760
  SET status = 'draft',
1685
1761
  published_at = NULL,
1686
1762
  publish_at = NULL,
1687
- updated_at = unixepoch()
1763
+ updated_at = unixepoch()${options.canonicalEdit ? ', canonical_version = canonical_version + 1' : ''}
1688
1764
  WHERE id = ?`,
1689
1765
  )
1690
1766
  .bind(id)
@@ -106,12 +106,32 @@ export async function renameTag(db: D1Database, tagId: string, label: string): P
106
106
  .prepare('UPDATE content_tags SET label = ?, slug = ? WHERE id = ?')
107
107
  .bind(tag.label, tag.slug, tagId),
108
108
  db.prepare('UPDATE content_tag_links SET label = ? WHERE tag_id = ?').bind(tag.label, tagId),
109
+ // A rename is a human-initiated canonical change to every item carrying
110
+ // the tag, exactly as topic rename is (engine/topics.ts).
111
+ bumpTaggedItems(db, tagId),
109
112
  ])
110
113
  return Boolean(res.meta && (res.meta as { changes?: number }).changes)
111
114
  }
112
115
 
113
116
  export async function deleteTag(db: D1Database, tagId: string): Promise<boolean> {
114
117
  // content_tag_links has ON DELETE CASCADE on tag_id (0001), so links go too.
115
- const res = await db.prepare('DELETE FROM content_tags WHERE id = ?').bind(tagId).run()
118
+ // The tagged items are bumped first, in the same batch, while the links that
119
+ // name them still exist.
120
+ const [, res] = await db.batch([
121
+ bumpTaggedItems(db, tagId),
122
+ db.prepare('DELETE FROM content_tags WHERE id = ?').bind(tagId),
123
+ ])
116
124
  return Boolean(res.meta && (res.meta as { changes?: number }).changes)
117
125
  }
126
+
127
+ /** canonical_version + 1 for every live item linked to the tag. */
128
+ function bumpTaggedItems(db: D1Database, tagId: string) {
129
+ return db
130
+ .prepare(
131
+ `UPDATE content_items
132
+ SET canonical_version = canonical_version + 1, updated_at = unixepoch()
133
+ WHERE deleted_at IS NULL
134
+ AND id IN (SELECT content_id FROM content_tag_links WHERE tag_id = ?)`,
135
+ )
136
+ .bind(tagId)
137
+ }
@@ -133,7 +133,10 @@ export async function renameTopic(
133
133
  const res = await db
134
134
  .prepare(
135
135
  `UPDATE content_items
136
- SET primary_topic = ?, updated_at = unixepoch()
136
+ SET primary_topic = ?, updated_at = unixepoch(),
137
+ -- A rename is a human-initiated canonical change to every item it
138
+ -- touches, exactly as an editor's per-item topic change is.
139
+ canonical_version = canonical_version + 1
137
140
  WHERE deleted_at IS NULL AND primary_topic = ?`,
138
141
  )
139
142
  .bind(nextSlug, currentSlug)
@@ -46,3 +46,47 @@ export async function frontsVideoSourceGeneration(
46
46
  export function videoSourceIngestableOverHttps(kind: string | null | undefined): boolean {
47
47
  return kind === 'http_url' || kind === 'dropbox'
48
48
  }
49
+
50
+ /**
51
+ * The URL shape Foundry's ingest accepts (capabilityplane/ingest.go
52
+ * `validHTTPSIngestURL`): https, a host, and no userinfo, query or
53
+ * fragment. A URL outside this shape is refused at the Go media probe with
54
+ * "artifact ingest probe source URL is invalid" — the failure that blocked
55
+ * a scheduled HomeFronts video on 2026-10-01T18:05Z. The IP-range checks
56
+ * (`PublicHTTPSIngestHost`) stay Foundry's: the CMS only refuses what no
57
+ * host would accept.
58
+ */
59
+ export function videoSourceUrlIngestableOverHttps(rawUrl: string | null | undefined): boolean {
60
+ if (!rawUrl) return false
61
+ let url: URL
62
+ try {
63
+ url = new URL(rawUrl)
64
+ } catch {
65
+ return false
66
+ }
67
+ return (
68
+ url.protocol === 'https:' &&
69
+ url.hostname !== '' &&
70
+ !url.username &&
71
+ !url.password &&
72
+ url.search === '' &&
73
+ url.hash === ''
74
+ )
75
+ }
76
+
77
+ /**
78
+ * A video source whose URL `videoSourceUrlIngestableOverHttps` refuses (a
79
+ * query string, fragment, userinfo or non-https scheme). Thrown by the
80
+ * publisher's video write so the save fails before a row can strand a
81
+ * scheduled video at the Go media probe. The message is the whole
82
+ * editor-facing error and deliberately echoes no part of the URL — a share
83
+ * link's path or query can carry its token.
84
+ */
85
+ export class VideoSourceNotIngestableError extends Error {
86
+ constructor() {
87
+ super(
88
+ 'Video source URL must be a direct HTTPS link with no query string or fragment — Foundry refuses to ingest it. A Fulcrum Drive share link must be pasted in its direct /download form.',
89
+ )
90
+ this.name = 'VideoSourceNotIngestableError'
91
+ }
92
+ }