@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.
- package/dist/cli/foundry-emit.d.ts +14 -0
- package/dist/cli/foundry-emit.d.ts.map +1 -0
- package/dist/cli/foundry-emit.js +69 -0
- package/dist/cli/foundry-emit.js.map +1 -0
- package/dist/engine/fronts-publish-intent.d.ts +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +12 -1
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/publication-receipt/receipt.d.ts +20 -20
- package/dist/engine/publisher.d.ts +16 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +55 -4
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +18 -1
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/topics.d.ts.map +1 -1
- package/dist/engine/topics.js +4 -1
- package/dist/engine/topics.js.map +1 -1
- package/dist/engine/video-source.d.ts +21 -0
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +40 -0
- package/dist/engine/video-source.js.map +1 -1
- package/dist/foundry-emit.d.ts +146 -0
- package/dist/foundry-emit.d.ts.map +1 -0
- package/dist/foundry-emit.js +189 -0
- package/dist/foundry-emit.js.map +1 -0
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +24 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +22 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +6 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +21 -13
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/analytics.d.ts +1 -0
- package/dist/routes/analytics.d.ts.map +1 -1
- package/dist/routes/analytics.js +20 -0
- package/dist/routes/analytics.js.map +1 -1
- package/dist/routes/content.d.ts +27 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +58 -12
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts +50 -0
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +540 -27
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/machine-context.d.ts +6 -0
- package/dist/routes/machine-context.d.ts.map +1 -0
- package/dist/routes/machine-context.js +20 -0
- package/dist/routes/machine-context.js.map +1 -0
- package/dist/routes/surveys.d.ts +14 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +515 -10
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +8 -8
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/surveys/access.d.ts +1 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +13 -4
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +100 -0
- package/dist/surveys/auto-label.d.ts.map +1 -0
- package/dist/surveys/auto-label.js +325 -0
- package/dist/surveys/auto-label.js.map +1 -0
- package/dist/surveys/schema.d.ts +38 -12
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +18 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +4 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/analytics.d.ts.map +1 -1
- package/dist/ui/api/analytics.js +5 -2
- package/dist/ui/api/analytics.js.map +1 -1
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
- package/dist/ui/api/surveys/[surveyId].js +28 -1
- package/dist/ui/api/surveys/[surveyId].js.map +1 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +2 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +1 -0
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +13 -8
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
- package/dist/ui/screens/PodcastListeningScreen.js +244 -0
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/analytics-data.d.ts +14 -0
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -0
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
- package/dist/ui/screens/podcast-listening-data.js +98 -0
- package/dist/ui/screens/podcast-listening-data.js.map +1 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -0
- package/dist/ui/screens/registry.js.map +1 -1
- package/docs/foundry-publish.md +208 -0
- package/migrations/0033_survey_answer_labels.sql +15 -0
- package/package.json +9 -3
- package/src/cli/foundry-emit.ts +85 -0
- package/src/engine/fronts-publish-intent.ts +23 -1
- package/src/engine/publisher.ts +80 -4
- package/src/engine/tags.ts +21 -1
- package/src/engine/topics.ts +4 -1
- package/src/engine/video-source.ts +44 -0
- package/src/foundry-emit.ts +255 -0
- package/src/integration/index.ts +9 -0
- package/src/integration/options.ts +23 -0
- package/src/providers/null.ts +6 -0
- package/src/providers/types.ts +52 -0
- package/src/routes/ai.ts +29 -17
- package/src/routes/analytics.ts +21 -0
- package/src/routes/content.ts +81 -15
- package/src/routes/foundry-publish.ts +746 -29
- package/src/routes/machine-context.ts +24 -0
- package/src/routes/surveys.ts +587 -12
- package/src/schema/migrations.ts +15 -0
- package/src/schema/tables.ts +2 -0
- package/src/surveys/access.ts +14 -4
- package/src/surveys/auto-label.ts +420 -0
- package/src/surveys/schema.ts +25 -1
- package/src/ui/api/_content-config.ts +6 -0
- package/src/ui/api/analytics.ts +5 -2
- package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
- package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +37 -3
- package/src/ui/commands.ts +2 -0
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/nav.ts +1 -0
- package/src/ui/screens/AnalyticsScreen.tsx +34 -6
- package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
- package/src/ui/screens/analytics-data.ts +18 -0
- package/src/ui/screens/podcast-listening-data.ts +114 -0
- package/src/ui/screens/registry.tsx +2 -0
package/docs/foundry-publish.md
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.8.23",
|
|
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
|
+
}
|
|
@@ -47,7 +47,11 @@ import {
|
|
|
47
47
|
podcastPublishBlocker,
|
|
48
48
|
validPodcastSourceUrl,
|
|
49
49
|
} from './podcast-source.js'
|
|
50
|
-
import {
|
|
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) ||
|
package/src/engine/publisher.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
validateDeclaredTagList,
|
|
31
31
|
validateStoredTaxonomyRows,
|
|
32
32
|
} from './taxonomy.js'
|
|
33
|
+
import { VideoSourceNotIngestableError, videoSourceUrlIngestableOverHttps } from './video-source.js'
|
|
33
34
|
|
|
34
35
|
export {
|
|
35
36
|
MAX_ITEM_TAG_COUNT,
|
|
@@ -625,6 +626,20 @@ export async function createContent(
|
|
|
625
626
|
db: D1Database,
|
|
626
627
|
input: CreateContentInput,
|
|
627
628
|
): Promise<{ id: string; slug: string }> {
|
|
629
|
+
// Refuse before the provider probe or any row write: an http_url source
|
|
630
|
+
// with a query, fragment, userinfo or non-https scheme can never pass
|
|
631
|
+
// Foundry's media probe, so storing it strands a scheduled video (the
|
|
632
|
+
// HomeFronts 2026-10-01T18:05Z incident). Dropbox links legitimately
|
|
633
|
+
// carry `?dl=1` and stay out of this check.
|
|
634
|
+
if (
|
|
635
|
+
input.type === 'video' &&
|
|
636
|
+
input.content.processingSourceUrl !== undefined &&
|
|
637
|
+
input.content.processingSourceUrl !== null &&
|
|
638
|
+
(input.content.processingSourceKind ?? 'http_url') === 'http_url' &&
|
|
639
|
+
!videoSourceUrlIngestableOverHttps(input.content.processingSourceUrl)
|
|
640
|
+
) {
|
|
641
|
+
throw new VideoSourceNotIngestableError()
|
|
642
|
+
}
|
|
628
643
|
const resolvedMediaDuration =
|
|
629
644
|
input.type === 'video'
|
|
630
645
|
? await resolveMediaDuration(
|
|
@@ -775,6 +790,21 @@ export async function getContentItem(
|
|
|
775
790
|
.first<ContentItemRecord>()
|
|
776
791
|
}
|
|
777
792
|
|
|
793
|
+
/**
|
|
794
|
+
* A human-initiated canonical change written outside updateContentItem (an
|
|
795
|
+
* editor-triggered AI write-back to a field updateContentItem does not carry):
|
|
796
|
+
* advances canonical_version exactly as updateContentItem's own UPDATE does.
|
|
797
|
+
* canonical_version is how the Foundry machine route knows the working copy is
|
|
798
|
+
* no longer only what its last PUT wrote (M3-18 resync guard).
|
|
799
|
+
*/
|
|
800
|
+
export function canonicalEditStatement(db: D1Database, contentId: string) {
|
|
801
|
+
return db
|
|
802
|
+
.prepare(
|
|
803
|
+
'UPDATE content_items SET canonical_version = canonical_version + 1, updated_at = unixepoch() WHERE id = ?',
|
|
804
|
+
)
|
|
805
|
+
.bind(contentId)
|
|
806
|
+
}
|
|
807
|
+
|
|
778
808
|
export async function updateContentItem(
|
|
779
809
|
db: D1Database,
|
|
780
810
|
id: string,
|
|
@@ -823,6 +853,16 @@ export async function updateContentItem(
|
|
|
823
853
|
const sourceKind = hasOwn(videoContent, 'processingSourceKind')
|
|
824
854
|
? (videoContent.processingSourceKind ?? null)
|
|
825
855
|
: current.processing_source_kind
|
|
856
|
+
// Same refusal as createContent, against the merged (input-or-stored)
|
|
857
|
+
// source: a row that already holds a query-bearing link is corrected
|
|
858
|
+
// at the next save, while the editor is still there to re-capture it.
|
|
859
|
+
if (
|
|
860
|
+
sourceUrl !== null &&
|
|
861
|
+
(sourceKind ?? 'http_url') === 'http_url' &&
|
|
862
|
+
!videoSourceUrlIngestableOverHttps(sourceUrl)
|
|
863
|
+
) {
|
|
864
|
+
throw new VideoSourceNotIngestableError()
|
|
865
|
+
}
|
|
826
866
|
const sourceChanged =
|
|
827
867
|
sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
|
|
828
868
|
const durationProvided = hasOwn(videoContent, 'durationSeconds')
|
|
@@ -997,8 +1037,8 @@ export async function updateContentItem(
|
|
|
997
1037
|
: existing.metadata_json
|
|
998
1038
|
|
|
999
1039
|
if (options.requiredStatus) {
|
|
1000
|
-
if (input.
|
|
1001
|
-
throw new TypeError('Status-constrained updates do not support
|
|
1040
|
+
if (input.relations !== undefined) {
|
|
1041
|
+
throw new TypeError('Status-constrained updates do not support relations')
|
|
1002
1042
|
}
|
|
1003
1043
|
if (!isBodyBackedContentType(existing.type)) {
|
|
1004
1044
|
throw new TypeError('Status-constrained updates require body-backed content')
|
|
@@ -1131,6 +1171,31 @@ export async function updateContentItem(
|
|
|
1131
1171
|
)
|
|
1132
1172
|
}
|
|
1133
1173
|
|
|
1174
|
+
if (tagPairs) {
|
|
1175
|
+
// Tag links join the same transaction under the same status predicate,
|
|
1176
|
+
// so a refused (non-matching status) update leaves the links untouched.
|
|
1177
|
+
// Tag dictionary rows are shared and idempotent, so they may be ensured
|
|
1178
|
+
// up front even when the guarded write below is refused.
|
|
1179
|
+
const tagRows = tagPairs.length > 0 ? await ensureTags(db, tagPairs) : []
|
|
1180
|
+
const statusGuard = `EXISTS (SELECT 1 FROM content_items
|
|
1181
|
+
WHERE id = ? AND status = ? AND deleted_at IS NULL)`
|
|
1182
|
+
statements.push(
|
|
1183
|
+
db
|
|
1184
|
+
.prepare(`DELETE FROM content_tag_links WHERE content_id = ? AND ${statusGuard}`)
|
|
1185
|
+
.bind(id, id, options.requiredStatus),
|
|
1186
|
+
)
|
|
1187
|
+
for (const tag of tagRows) {
|
|
1188
|
+
statements.push(
|
|
1189
|
+
db
|
|
1190
|
+
.prepare(
|
|
1191
|
+
`INSERT INTO content_tag_links (content_id, tag_id, label)
|
|
1192
|
+
SELECT ?, ?, ? WHERE ${statusGuard}`,
|
|
1193
|
+
)
|
|
1194
|
+
.bind(id, tag.id, tag.label, id, options.requiredStatus),
|
|
1195
|
+
)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1134
1199
|
const statusResultIndex = statements.length
|
|
1135
1200
|
statements.push(
|
|
1136
1201
|
db
|
|
@@ -1677,14 +1742,25 @@ export async function claimScheduledForPublish(
|
|
|
1677
1742
|
return changes > 0
|
|
1678
1743
|
}
|
|
1679
1744
|
|
|
1680
|
-
export async function unpublishContent(
|
|
1745
|
+
export async function unpublishContent(
|
|
1746
|
+
db: D1Database,
|
|
1747
|
+
id: string,
|
|
1748
|
+
options: {
|
|
1749
|
+
/**
|
|
1750
|
+
* A person pulled the item: advance canonical_version in the same UPDATE,
|
|
1751
|
+
* so a machine publisher can never put it back without a new write of its
|
|
1752
|
+
* own (the Foundry machine route's human-edit guard, M3-18).
|
|
1753
|
+
*/
|
|
1754
|
+
canonicalEdit?: boolean
|
|
1755
|
+
} = {},
|
|
1756
|
+
): Promise<void> {
|
|
1681
1757
|
await db
|
|
1682
1758
|
.prepare(
|
|
1683
1759
|
`UPDATE content_items
|
|
1684
1760
|
SET status = 'draft',
|
|
1685
1761
|
published_at = NULL,
|
|
1686
1762
|
publish_at = NULL,
|
|
1687
|
-
updated_at = unixepoch()
|
|
1763
|
+
updated_at = unixepoch()${options.canonicalEdit ? ', canonical_version = canonical_version + 1' : ''}
|
|
1688
1764
|
WHERE id = ?`,
|
|
1689
1765
|
)
|
|
1690
1766
|
.bind(id)
|
package/src/engine/tags.ts
CHANGED
|
@@ -106,12 +106,32 @@ export async function renameTag(db: D1Database, tagId: string, label: string): P
|
|
|
106
106
|
.prepare('UPDATE content_tags SET label = ?, slug = ? WHERE id = ?')
|
|
107
107
|
.bind(tag.label, tag.slug, tagId),
|
|
108
108
|
db.prepare('UPDATE content_tag_links SET label = ? WHERE tag_id = ?').bind(tag.label, tagId),
|
|
109
|
+
// A rename is a human-initiated canonical change to every item carrying
|
|
110
|
+
// the tag, exactly as topic rename is (engine/topics.ts).
|
|
111
|
+
bumpTaggedItems(db, tagId),
|
|
109
112
|
])
|
|
110
113
|
return Boolean(res.meta && (res.meta as { changes?: number }).changes)
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
export async function deleteTag(db: D1Database, tagId: string): Promise<boolean> {
|
|
114
117
|
// content_tag_links has ON DELETE CASCADE on tag_id (0001), so links go too.
|
|
115
|
-
|
|
118
|
+
// The tagged items are bumped first, in the same batch, while the links that
|
|
119
|
+
// name them still exist.
|
|
120
|
+
const [, res] = await db.batch([
|
|
121
|
+
bumpTaggedItems(db, tagId),
|
|
122
|
+
db.prepare('DELETE FROM content_tags WHERE id = ?').bind(tagId),
|
|
123
|
+
])
|
|
116
124
|
return Boolean(res.meta && (res.meta as { changes?: number }).changes)
|
|
117
125
|
}
|
|
126
|
+
|
|
127
|
+
/** canonical_version + 1 for every live item linked to the tag. */
|
|
128
|
+
function bumpTaggedItems(db: D1Database, tagId: string) {
|
|
129
|
+
return db
|
|
130
|
+
.prepare(
|
|
131
|
+
`UPDATE content_items
|
|
132
|
+
SET canonical_version = canonical_version + 1, updated_at = unixepoch()
|
|
133
|
+
WHERE deleted_at IS NULL
|
|
134
|
+
AND id IN (SELECT content_id FROM content_tag_links WHERE tag_id = ?)`,
|
|
135
|
+
)
|
|
136
|
+
.bind(tagId)
|
|
137
|
+
}
|
package/src/engine/topics.ts
CHANGED
|
@@ -133,7 +133,10 @@ export async function renameTopic(
|
|
|
133
133
|
const res = await db
|
|
134
134
|
.prepare(
|
|
135
135
|
`UPDATE content_items
|
|
136
|
-
SET primary_topic = ?, updated_at = unixepoch()
|
|
136
|
+
SET primary_topic = ?, updated_at = unixepoch(),
|
|
137
|
+
-- A rename is a human-initiated canonical change to every item it
|
|
138
|
+
-- touches, exactly as an editor's per-item topic change is.
|
|
139
|
+
canonical_version = canonical_version + 1
|
|
137
140
|
WHERE deleted_at IS NULL AND primary_topic = ?`,
|
|
138
141
|
)
|
|
139
142
|
.bind(nextSlug, currentSlug)
|
|
@@ -46,3 +46,47 @@ export async function frontsVideoSourceGeneration(
|
|
|
46
46
|
export function videoSourceIngestableOverHttps(kind: string | null | undefined): boolean {
|
|
47
47
|
return kind === 'http_url' || kind === 'dropbox'
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The URL shape Foundry's ingest accepts (capabilityplane/ingest.go
|
|
52
|
+
* `validHTTPSIngestURL`): https, a host, and no userinfo, query or
|
|
53
|
+
* fragment. A URL outside this shape is refused at the Go media probe with
|
|
54
|
+
* "artifact ingest probe source URL is invalid" — the failure that blocked
|
|
55
|
+
* a scheduled HomeFronts video on 2026-10-01T18:05Z. The IP-range checks
|
|
56
|
+
* (`PublicHTTPSIngestHost`) stay Foundry's: the CMS only refuses what no
|
|
57
|
+
* host would accept.
|
|
58
|
+
*/
|
|
59
|
+
export function videoSourceUrlIngestableOverHttps(rawUrl: string | null | undefined): boolean {
|
|
60
|
+
if (!rawUrl) return false
|
|
61
|
+
let url: URL
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(rawUrl)
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
url.protocol === 'https:' &&
|
|
69
|
+
url.hostname !== '' &&
|
|
70
|
+
!url.username &&
|
|
71
|
+
!url.password &&
|
|
72
|
+
url.search === '' &&
|
|
73
|
+
url.hash === ''
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A video source whose URL `videoSourceUrlIngestableOverHttps` refuses (a
|
|
79
|
+
* query string, fragment, userinfo or non-https scheme). Thrown by the
|
|
80
|
+
* publisher's video write so the save fails before a row can strand a
|
|
81
|
+
* scheduled video at the Go media probe. The message is the whole
|
|
82
|
+
* editor-facing error and deliberately echoes no part of the URL — a share
|
|
83
|
+
* link's path or query can carry its token.
|
|
84
|
+
*/
|
|
85
|
+
export class VideoSourceNotIngestableError extends Error {
|
|
86
|
+
constructor() {
|
|
87
|
+
super(
|
|
88
|
+
'Video source URL must be a direct HTTPS link with no query string or fragment — Foundry refuses to ingest it. A Fulcrum Drive share link must be pasted in its direct /download form.',
|
|
89
|
+
)
|
|
90
|
+
this.name = 'VideoSourceNotIngestableError'
|
|
91
|
+
}
|
|
92
|
+
}
|