@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.
- 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/lib/publish-timezone.d.ts +38 -0
- package/dist/lib/publish-timezone.d.ts.map +1 -0
- package/dist/lib/publish-timezone.js +135 -0
- package/dist/lib/publish-timezone.js.map +1 -0
- 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 +60 -114
- 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/inspector/PublishTab.d.ts +7 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +24 -26
- package/dist/ui/inspector/PublishTab.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/lib/publish-timezone.ts +154 -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 +86 -131
- 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/inspector/PublishTab.tsx +33 -25
- package/src/ui/nav.ts +1 -0
- package/src/ui/screens/AnalyticsScreen.tsx +34 -6
- package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
- package/src/ui/screens/analytics-data.ts +18 -0
- package/src/ui/screens/podcast-listening-data.ts +114 -0
- package/src/ui/screens/registry.tsx +2 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/foundry-signing';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|