@growth-labs/cms 0.7.0 → 0.8.2
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/engine/fronts-publish-intent.d.ts +25 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +64 -11
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/video-source.d.ts +26 -0
- package/dist/engine/video-source.d.ts.map +1 -0
- package/dist/engine/video-source.js +31 -0
- package/dist/engine/video-source.js.map +1 -0
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +7 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/providers/types.d.ts +40 -3
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +17 -0
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/calendar.d.ts +26 -0
- package/dist/routes/calendar.d.ts.map +1 -1
- package/dist/routes/calendar.js +41 -0
- package/dist/routes/calendar.js.map +1 -1
- package/dist/routes/content-insights.d.ts +9 -0
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +35 -0
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/content.d.ts +2 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +14 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +8 -2
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +1 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/calendar.d.ts.map +1 -1
- package/dist/ui/api/calendar.js +5 -1
- package/dist/ui/api/calendar.js.map +1 -1
- package/dist/ui/api/content/insights-inputs.d.ts +3 -0
- package/dist/ui/api/content/insights-inputs.d.ts.map +1 -0
- package/dist/ui/api/content/insights-inputs.js +15 -0
- package/dist/ui/api/content/insights-inputs.js.map +1 -0
- package/dist/ui/screens/CalendarScreen.d.ts.map +1 -1
- package/dist/ui/screens/CalendarScreen.js +10 -1
- package/dist/ui/screens/CalendarScreen.js.map +1 -1
- package/dist/ui/screens/calendar-data.d.ts +3 -0
- package/dist/ui/screens/calendar-data.d.ts.map +1 -1
- package/dist/ui/screens/calendar-data.js.map +1 -1
- package/dist/ui/screens/go-publish-badge.d.ts +11 -0
- package/dist/ui/screens/go-publish-badge.d.ts.map +1 -0
- package/dist/ui/screens/go-publish-badge.js +47 -0
- package/dist/ui/screens/go-publish-badge.js.map +1 -0
- package/package.json +1 -1
- package/src/engine/fronts-publish-intent.ts +104 -15
- package/src/engine/index.ts +5 -0
- package/src/engine/video-source.ts +45 -0
- package/src/integration/index.ts +7 -0
- package/src/providers/types.ts +51 -1
- package/src/routes/calendar.ts +72 -0
- package/src/routes/content-insights.ts +61 -0
- package/src/routes/content.ts +2 -0
- package/src/routes/fronts-publish.ts +8 -2
- package/src/ui/api/_content-config.ts +2 -0
- package/src/ui/api/calendar.ts +5 -1
- package/src/ui/api/content/insights-inputs.ts +26 -0
- package/src/ui/screens/CalendarScreen.tsx +22 -0
- package/src/ui/screens/calendar-data.ts +4 -0
- package/src/ui/screens/go-publish-badge.ts +55 -0
package/src/engine/index.ts
CHANGED
|
@@ -409,6 +409,11 @@ export {
|
|
|
409
409
|
validateArticleBody,
|
|
410
410
|
warnErrors,
|
|
411
411
|
} from './validator/index.js'
|
|
412
|
+
export {
|
|
413
|
+
type FrontsVideoSourceSnapshot,
|
|
414
|
+
frontsVideoSourceGeneration,
|
|
415
|
+
videoSourceIngestableOverHttps,
|
|
416
|
+
} from './video-source.js'
|
|
412
417
|
// Webhook signer + default dispatch impl — HMAC-SHA256 via Web Crypto (P7 Task 9).
|
|
413
418
|
export {
|
|
414
419
|
buildDefaultDispatchWebhook,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Current CMS evidence used by the signed schedule and media callback guards. */
|
|
2
|
+
export interface FrontsVideoSourceSnapshot {
|
|
3
|
+
content_id: string
|
|
4
|
+
slug: string
|
|
5
|
+
canonical_version: number
|
|
6
|
+
processing_source_url: string | null
|
|
7
|
+
processing_source_kind: string | null
|
|
8
|
+
processing_trigger_token: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable over polling, machine media callbacks and rescheduling; canonical
|
|
13
|
+
* editor/source changes invalidate the old production. The raw trigger token
|
|
14
|
+
* and URL never need to appear in callback or notification identities. The
|
|
15
|
+
* Go consumer's contract is `fronts-video-source.v1` (foundry
|
|
16
|
+
* `publishplane/masthead/videosource.schema.json`, exported by
|
|
17
|
+
* `foundry-capability-probe --contract`).
|
|
18
|
+
*/
|
|
19
|
+
export async function frontsVideoSourceGeneration(
|
|
20
|
+
input: FrontsVideoSourceSnapshot,
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
const bytes = new TextEncoder().encode(
|
|
23
|
+
JSON.stringify([
|
|
24
|
+
'fronts-video-source.v1',
|
|
25
|
+
input.content_id,
|
|
26
|
+
input.slug,
|
|
27
|
+
input.canonical_version,
|
|
28
|
+
input.processing_source_url,
|
|
29
|
+
input.processing_source_kind,
|
|
30
|
+
input.processing_trigger_token,
|
|
31
|
+
]),
|
|
32
|
+
)
|
|
33
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
34
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Source kinds the Go media.pipeline can ingest over plain HTTPS
|
|
39
|
+
* (`ingest.https.verified`): a direct URL and a Dropbox share link, both of
|
|
40
|
+
* which are fetched as-is. A Trello attachment needs the board credential
|
|
41
|
+
* and stays on the host's own dispatch path.
|
|
42
|
+
*/
|
|
43
|
+
export function videoSourceIngestableOverHttps(kind: string | null | undefined): boolean {
|
|
44
|
+
return kind === 'http_url' || kind === 'dropbox'
|
|
45
|
+
}
|
package/src/integration/index.ts
CHANGED
|
@@ -111,6 +111,13 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
111
111
|
pattern: `${base}/api/content/insights-ingest`,
|
|
112
112
|
entrypoint: resolveEntrypoint('../ui/api/content/insights-ingest'),
|
|
113
113
|
})
|
|
114
|
+
// Author denylist + existing-content coverage index for foundryd's
|
|
115
|
+
// weekly content-insights funnel (W1-07 PR 6) — M2M poll, HMAC-guarded
|
|
116
|
+
// same as fronts/schedule below, sibling of insights-ingest.
|
|
117
|
+
injectRoute({
|
|
118
|
+
pattern: `${base}/api/content/insights-inputs`,
|
|
119
|
+
entrypoint: resolveEntrypoint('../ui/api/content/insights-inputs'),
|
|
120
|
+
})
|
|
114
121
|
// Fronts publish path (additive, flag-gated) — M2M siblings of the
|
|
115
122
|
// foundry-callback / insights-ingest routes. HMAC-guarded.
|
|
116
123
|
injectRoute({
|
package/src/providers/types.ts
CHANGED
|
@@ -402,9 +402,59 @@ export interface FoundryDispatchResult {
|
|
|
402
402
|
/** Return source evidence; the CMS owns its atomic capture with the token. */
|
|
403
403
|
podcastSource?: { url: string; kind: 'https' }
|
|
404
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* The Go publish state of one media item as the host reads it from its own
|
|
407
|
+
* receipts (W1-08b): the state bucket the schedule badge renders plus what
|
|
408
|
+
* the badge shows on hover. Every field but `state` is optional so a host
|
|
409
|
+
* that still answers `{ state }` keeps working; `state` is one of
|
|
410
|
+
* `goPublishStateValues` for a Go-plane host and free text otherwise.
|
|
411
|
+
*/
|
|
412
|
+
export const goPublishStateValues = [
|
|
413
|
+
'pending',
|
|
414
|
+
'captured',
|
|
415
|
+
'processing',
|
|
416
|
+
'media_ready',
|
|
417
|
+
'publishing',
|
|
418
|
+
'published',
|
|
419
|
+
'late',
|
|
420
|
+
'failed',
|
|
421
|
+
] as const
|
|
422
|
+
export type GoPublishState = (typeof goPublishStateValues)[number]
|
|
423
|
+
|
|
424
|
+
export interface FoundryJobStatus {
|
|
425
|
+
state: GoPublishState | (string & {})
|
|
426
|
+
kind?: 'video' | 'podcast'
|
|
427
|
+
contentId?: string
|
|
428
|
+
contentStatus?: string
|
|
429
|
+
/** The id callbacks arrive under; null before the source is captured. */
|
|
430
|
+
correlationId?: string | null
|
|
431
|
+
/** The host's raw processing state (video_content.processing_state), when it has one. */
|
|
432
|
+
processingState?: string | null
|
|
433
|
+
/** True when the media the site serves exists (HLS ready / audio present). */
|
|
434
|
+
mediaReady?: boolean
|
|
435
|
+
/** Latest production or publish receipt kind, e.g. `hls.ready`, `publish:live`. */
|
|
436
|
+
eventKind?: string | null
|
|
437
|
+
/** Unix seconds of that latest receipt. */
|
|
438
|
+
receiptAt?: number | null
|
|
439
|
+
publishStatus?: 'queued' | 'publishing' | 'live' | 'late' | 'failed' | null
|
|
440
|
+
/** Why the run is blocked (a production error, the last publish error). */
|
|
441
|
+
blockedReason?: string | null
|
|
442
|
+
placement?: {
|
|
443
|
+
storageClass: 'public' | 'private'
|
|
444
|
+
verified: boolean
|
|
445
|
+
verifiedAt: number
|
|
446
|
+
} | null
|
|
447
|
+
}
|
|
448
|
+
|
|
405
449
|
export interface FoundryDispatch {
|
|
406
450
|
dispatchVideo(jobSpec: FoundryJobSpec): Promise<FoundryDispatchResult>
|
|
407
|
-
|
|
451
|
+
/**
|
|
452
|
+
* Read a media item's processing / Go publish state. `reference` is the
|
|
453
|
+
* production correlation id the host handed back from dispatchVideo, or
|
|
454
|
+
* a content id (video or podcast) so the schedule views can ask per row.
|
|
455
|
+
* Null when the host knows nothing about the reference.
|
|
456
|
+
*/
|
|
457
|
+
getJob(reference: string): Promise<FoundryJobStatus | null>
|
|
408
458
|
}
|
|
409
459
|
|
|
410
460
|
export type MediaSourceKind = 'video' | 'podcast'
|
package/src/routes/calendar.ts
CHANGED
|
@@ -9,11 +9,19 @@
|
|
|
9
9
|
//
|
|
10
10
|
// No provider calls: the editorial calendar is fully first-party D1.
|
|
11
11
|
|
|
12
|
+
import type { FoundryDispatch, FoundryJobStatus } from '../providers/types.js'
|
|
12
13
|
import type { Authz, RouteContext } from './context.js'
|
|
13
14
|
import { json } from './context.js'
|
|
14
15
|
|
|
15
16
|
export interface CalendarRouteConfig {
|
|
16
17
|
authz: Authz
|
|
18
|
+
/**
|
|
19
|
+
* W1-08b: when the host supplies its Foundry provider, every scheduled
|
|
20
|
+
* video/podcast item in the range carries its Go publish state
|
|
21
|
+
* (`goPublish`) read through `getJob(contentId)` -- the badge the
|
|
22
|
+
* calendar chip renders. Absent provider → `goPublish` is omitted.
|
|
23
|
+
*/
|
|
24
|
+
foundryVideo?: Pick<FoundryDispatch, 'getJob'>
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export interface CalendarRouteHandlers {
|
|
@@ -29,6 +37,60 @@ export interface CalendarRouteItem {
|
|
|
29
37
|
slug: string
|
|
30
38
|
/** ISO-8601 date string derived from publish_at (scheduled) or published_at (published). */
|
|
31
39
|
date: string
|
|
40
|
+
/**
|
|
41
|
+
* Go publish state of a scheduled video/podcast item (W1-08b). `null`
|
|
42
|
+
* when the host provider knows nothing about the item; a `state` of
|
|
43
|
+
* `unavailable` with `blockedReason` when the provider itself failed --
|
|
44
|
+
* the badge says so rather than rendering nothing.
|
|
45
|
+
*/
|
|
46
|
+
goPublish?: CalendarGoPublish | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CalendarGoPublish {
|
|
50
|
+
state: FoundryJobStatus['state'] | 'unavailable'
|
|
51
|
+
eventKind: string | null
|
|
52
|
+
receiptAt: number | null
|
|
53
|
+
correlationId: string | null
|
|
54
|
+
blockedReason: string | null
|
|
55
|
+
processingState: string | null
|
|
56
|
+
mediaReady: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const GO_PUBLISH_TYPES = new Set(['video', 'podcast'])
|
|
60
|
+
|
|
61
|
+
/** Fold a provider answer into the calendar's badge shape. */
|
|
62
|
+
export function toCalendarGoPublish(status: FoundryJobStatus | null): CalendarGoPublish | null {
|
|
63
|
+
if (!status) return null
|
|
64
|
+
return {
|
|
65
|
+
state: status.state,
|
|
66
|
+
eventKind: status.eventKind ?? null,
|
|
67
|
+
receiptAt: status.receiptAt ?? null,
|
|
68
|
+
correlationId: status.correlationId ?? null,
|
|
69
|
+
blockedReason: status.blockedReason ?? null,
|
|
70
|
+
processingState: status.processingState ?? null,
|
|
71
|
+
mediaReady: status.mediaReady ?? false,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readGoPublish(
|
|
76
|
+
provider: Pick<FoundryDispatch, 'getJob'>,
|
|
77
|
+
contentId: string,
|
|
78
|
+
): Promise<CalendarGoPublish | null> {
|
|
79
|
+
try {
|
|
80
|
+
return toCalendarGoPublish(await provider.getJob(contentId))
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
83
|
+
console.warn('calendar: Go publish state unavailable', { contentId, reason })
|
|
84
|
+
return {
|
|
85
|
+
state: 'unavailable',
|
|
86
|
+
eventKind: null,
|
|
87
|
+
receiptAt: null,
|
|
88
|
+
correlationId: null,
|
|
89
|
+
blockedReason: reason,
|
|
90
|
+
processingState: null,
|
|
91
|
+
mediaReady: false,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
32
94
|
}
|
|
33
95
|
|
|
34
96
|
/**
|
|
@@ -121,6 +183,16 @@ export function createCalendarRoutes(config: CalendarRouteConfig): CalendarRoute
|
|
|
121
183
|
date: toIso(row.date_unix),
|
|
122
184
|
}))
|
|
123
185
|
|
|
186
|
+
const provider = config.foundryVideo
|
|
187
|
+
if (provider) {
|
|
188
|
+
await Promise.all(
|
|
189
|
+
items.map(async (item) => {
|
|
190
|
+
if (item.status !== 'scheduled' || !GO_PUBLISH_TYPES.has(item.type)) return
|
|
191
|
+
item.goPublish = await readGoPublish(provider, item.id)
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
124
196
|
return json({ items })
|
|
125
197
|
},
|
|
126
198
|
}
|
|
@@ -13,6 +13,10 @@ import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
|
|
|
13
13
|
import type { CmsRouteConfig } from './config.js'
|
|
14
14
|
import { resolveConfig } from './config.js'
|
|
15
15
|
import { json, type RouteContext } from './context.js'
|
|
16
|
+
import {
|
|
17
|
+
resolveFoundryHmacSecret as resolveFoundryHmacSecretForInputs,
|
|
18
|
+
verifyFoundrySignedRequest,
|
|
19
|
+
} from './fronts-publish.js'
|
|
16
20
|
|
|
17
21
|
const DismissSchema = z
|
|
18
22
|
.object({
|
|
@@ -72,8 +76,32 @@ export interface ContentInsightsRouteHandlers {
|
|
|
72
76
|
undismiss(ctx: RouteContext): Promise<Response>
|
|
73
77
|
/** POST /content/insights-ingest — HMAC machine ingest (replay-safe replace). */
|
|
74
78
|
ingest(ctx: RouteContext): Promise<Response>
|
|
79
|
+
/**
|
|
80
|
+
* GET /content/insights-inputs?site=<id> — the funnel's author denylist +
|
|
81
|
+
* existing-content coverage index for this site, read by foundryd's
|
|
82
|
+
* sitebrain.insights.run schedule (W1-07 PR 6). Same M2M signing family as
|
|
83
|
+
* fronts/schedule (X-Foundry-Timestamp + X-Foundry-Signature over the raw
|
|
84
|
+
* query string, verified against FOUNDRY_HMAC_SECRET) -- a sibling poll,
|
|
85
|
+
* not a new auth scheme.
|
|
86
|
+
*/
|
|
87
|
+
insightsInputs(ctx: RouteContext): Promise<Response>
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
/** authors row shape this route returns: just enough for the author denylist,
|
|
91
|
+
* never email/bio/social -- the funnel matches on name/slug only. */
|
|
92
|
+
const AUTHORS_QUERY = 'SELECT name, slug FROM authors ORDER BY name LIMIT 1000'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Existing-content index for lexical coverage: every content item an editor
|
|
96
|
+
* could currently be working on or has already shipped, so the funnel can
|
|
97
|
+
* skip a candidate that duplicates it. status/deleted_at match the-foundry's
|
|
98
|
+
* own SiteContentItemSchema (content-insights/ledger.ts, read-only) exactly,
|
|
99
|
+
* so this row shape ports verbatim.
|
|
100
|
+
*/
|
|
101
|
+
const EXISTING_CONTENT_QUERY = `SELECT title, slug FROM content_items
|
|
102
|
+
WHERE status IN ('published', 'draft', 'scheduled', 'review') AND deleted_at IS NULL
|
|
103
|
+
LIMIT 5000`
|
|
104
|
+
|
|
77
105
|
export function createContentInsightsRoutes(
|
|
78
106
|
config: ContentInsightsRouteConfig,
|
|
79
107
|
): ContentInsightsRouteHandlers {
|
|
@@ -181,5 +209,38 @@ export function createContentInsightsRoutes(
|
|
|
181
209
|
throw error
|
|
182
210
|
}
|
|
183
211
|
},
|
|
212
|
+
|
|
213
|
+
// HMAC-guarded machine poll -- NOT behind requirePublisher, same M2M
|
|
214
|
+
// family as fronts/schedule (timestamp-bound signature over the query
|
|
215
|
+
// string; a GET has no body to bind freshness to instead).
|
|
216
|
+
async insightsInputs(ctx) {
|
|
217
|
+
const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env)
|
|
218
|
+
if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
|
|
219
|
+
|
|
220
|
+
const url = new URL(ctx.request.url)
|
|
221
|
+
const denied = await verifyFoundrySignedRequest(
|
|
222
|
+
ctx.request,
|
|
223
|
+
hmacSecret,
|
|
224
|
+
url.search,
|
|
225
|
+
'content/insights-inputs',
|
|
226
|
+
)
|
|
227
|
+
if (denied) return denied
|
|
228
|
+
|
|
229
|
+
const requestedSite = url.searchParams.get('site')
|
|
230
|
+
const authorsResult = await ctx.db.prepare(AUTHORS_QUERY).all<{
|
|
231
|
+
name: string
|
|
232
|
+
slug: string
|
|
233
|
+
}>()
|
|
234
|
+
const existingResult = await ctx.db.prepare(EXISTING_CONTENT_QUERY).all<{
|
|
235
|
+
title: string
|
|
236
|
+
slug: string
|
|
237
|
+
}>()
|
|
238
|
+
return json({
|
|
239
|
+
ok: true,
|
|
240
|
+
site: requestedSite ?? siteId,
|
|
241
|
+
authors: authorsResult.results ?? [],
|
|
242
|
+
existing: existingResult.results ?? [],
|
|
243
|
+
})
|
|
244
|
+
},
|
|
184
245
|
}
|
|
185
246
|
}
|
package/src/routes/content.ts
CHANGED
|
@@ -199,6 +199,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
199
199
|
publishReadiness?: PublishReadinessHook
|
|
200
200
|
/** Host's existing production rollback control; omitted means enabled. */
|
|
201
201
|
podcastSourceProductionEnabled?: boolean
|
|
202
|
+
/** The video twin (FRONTS_VIDEO_DISPATCH_TARGET on the host); omitted means enabled. */
|
|
203
|
+
videoSourceProductionEnabled?: boolean
|
|
202
204
|
/** Internal machine-route constraint: atomically update only in this status. */
|
|
203
205
|
updateStatusConstraint?: ContentStatus
|
|
204
206
|
/**
|
|
@@ -44,7 +44,12 @@ import { json, type RouteContext } from './context.js'
|
|
|
44
44
|
* (test injection / per-Worker secret) or a Secrets Store binding `{ get() }`.
|
|
45
45
|
* Returns null → the route answers 500 "not configured".
|
|
46
46
|
*/
|
|
47
|
-
|
|
47
|
+
// Exported (W1-07 PR: content/insights-inputs) so a sibling M2M route can
|
|
48
|
+
// reuse the SAME timestamp+signature verification without duplicating it --
|
|
49
|
+
// content-insights.ts's insightsInputs handler is a GET poll exactly like
|
|
50
|
+
// `schedule` below, and belongs on the same FOUNDRY_HMAC_SECRET/verification
|
|
51
|
+
// family rather than inventing a second one.
|
|
52
|
+
export async function resolveFoundryHmacSecret(
|
|
48
53
|
env: Record<string, unknown> | undefined,
|
|
49
54
|
): Promise<string | null> {
|
|
50
55
|
const raw = env?.FOUNDRY_HMAC_SECRET
|
|
@@ -78,7 +83,7 @@ function parseTimestampSeconds(raw: string): number | null {
|
|
|
78
83
|
* = HMAC over `${timestamp}\n${payload}`. Returns null when authentic, or a 401
|
|
79
84
|
* Response (with a loud warn log) to short-circuit the handler.
|
|
80
85
|
*/
|
|
81
|
-
async function verifyFoundrySignedRequest(
|
|
86
|
+
export async function verifyFoundrySignedRequest(
|
|
82
87
|
request: Request,
|
|
83
88
|
secret: string,
|
|
84
89
|
payload: string,
|
|
@@ -199,6 +204,7 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
|
|
|
199
204
|
nowSeconds: now,
|
|
200
205
|
mediaPublicDomain: resolved.mediaPublicDomain,
|
|
201
206
|
podcastSourceProductionEnabled: config.podcastSourceProductionEnabled,
|
|
207
|
+
videoSourceProductionEnabled: config.videoSourceProductionEnabled,
|
|
202
208
|
// Site-RELATIVE paths: the CMS does not know the site origin, and the
|
|
203
209
|
// consumer joins its own trusted configured origin. A host-supplied
|
|
204
210
|
// origin is deliberately deferred (packages-docs/cms.md).
|
|
@@ -26,6 +26,7 @@ type CmsHooksLocals = ContentRouteConfig['hooks'] & {
|
|
|
26
26
|
dispatchWebhook?: DispatchWebhookHook
|
|
27
27
|
publishReadiness?: PublishReadinessHook
|
|
28
28
|
podcastSourceProductionEnabled?: boolean
|
|
29
|
+
videoSourceProductionEnabled?: boolean
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -84,5 +85,6 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
|
|
|
84
85
|
dispatchWebhook: hooks.dispatchWebhook,
|
|
85
86
|
publishReadiness: hooks.publishReadiness,
|
|
86
87
|
podcastSourceProductionEnabled: hooks.podcastSourceProductionEnabled,
|
|
88
|
+
videoSourceProductionEnabled: hooks.videoSourceProductionEnabled,
|
|
87
89
|
}
|
|
88
90
|
}
|
package/src/ui/api/calendar.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/ui/api/calendar.ts — Astro endpoint adapter for GET /admin/api/calendar.
|
|
2
2
|
// Maps the APIContext onto a RouteContext and delegates to createCalendarRoutes().month.
|
|
3
|
-
// The calendar is first-party D1
|
|
3
|
+
// The calendar is first-party D1; the host's Foundry provider (locals.cmsHooks.foundryVideo)
|
|
4
|
+
// adds the Go publish badge state to scheduled media items when present (W1-08b).
|
|
4
5
|
|
|
5
6
|
import type { APIContext } from 'astro'
|
|
6
7
|
import { type CalendarRouteConfig, createCalendarRoutes } from '../../routes/calendar.js'
|
|
@@ -13,8 +14,11 @@ function toCtx(c: APIContext): RouteContext {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function calendarConfig(c: APIContext): CalendarRouteConfig {
|
|
17
|
+
const hooks = (c.locals as { cmsHooks?: { foundryVideo?: CalendarRouteConfig['foundryVideo'] } })
|
|
18
|
+
.cmsHooks
|
|
16
19
|
return {
|
|
17
20
|
authz: buildSiteAuthz(c),
|
|
21
|
+
foundryVideo: hooks?.foundryVideo,
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
24
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/ui/api/content/insights-inputs.ts
|
|
2
|
+
// GET /admin/api/content/insights-inputs?site=<id> — the funnel's author
|
|
3
|
+
// denylist + existing-content coverage index. Machine-to-machine poll (same
|
|
4
|
+
// X-Foundry-Timestamp + X-Foundry-Signature family as fronts/schedule) for
|
|
5
|
+
// foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
|
|
6
|
+
// requirePublisher. Sibling of insights-ingest under /content/.
|
|
7
|
+
import type { APIContext } from 'astro'
|
|
8
|
+
import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
|
|
9
|
+
import type { RouteContext } from '../../../routes/context.js'
|
|
10
|
+
import { buildSiteAuthz } from '../_authz.js'
|
|
11
|
+
|
|
12
|
+
function toCtx(c: APIContext): RouteContext {
|
|
13
|
+
const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
|
|
14
|
+
return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function siteId(c: APIContext): string {
|
|
18
|
+
const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
|
|
19
|
+
return (env.ACTIVE_WORKSPACE_ID as string | undefined) ?? 'default'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const GET = (c: APIContext) =>
|
|
23
|
+
createContentInsightsRoutes({
|
|
24
|
+
authz: buildSiteAuthz(c),
|
|
25
|
+
activeWorkspaceId: siteId(c),
|
|
26
|
+
}).insightsInputs(toCtx(c))
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
19
19
|
import { Icon } from '../icons.js'
|
|
20
20
|
import { buildCalendarMonth, type CalendarCell, type CalendarItem } from './calendar-data.js'
|
|
21
|
+
import { goPublishBadge } from './go-publish-badge.js'
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Props
|
|
@@ -483,6 +484,10 @@ function CalendarItemChip({
|
|
|
483
484
|
const border = TYPE_COLOR[item.type] ?? 'var(--accent)'
|
|
484
485
|
const statusIcon = STATUS_ICON[item.status]
|
|
485
486
|
const statusColor = STATUS_ICON_COLOR[item.status] ?? 'var(--ink-faint)'
|
|
487
|
+
// W1-08b: the Go publish state of a scheduled media item, from the
|
|
488
|
+
// host's receipts (route: goPublish). Hover shows the receipt and the
|
|
489
|
+
// correlation id; the pill vocabulary is the Foundry tab's.
|
|
490
|
+
const badge = goPublishBadge(item.goPublish)
|
|
486
491
|
|
|
487
492
|
return (
|
|
488
493
|
<div
|
|
@@ -520,6 +525,23 @@ function CalendarItemChip({
|
|
|
520
525
|
>
|
|
521
526
|
{item.title || '(Untitled)'}
|
|
522
527
|
</button>
|
|
528
|
+
{badge && (
|
|
529
|
+
<span
|
|
530
|
+
className={
|
|
531
|
+
badge.pill === 'ready'
|
|
532
|
+
? 'pill published'
|
|
533
|
+
: badge.pill === 'failed'
|
|
534
|
+
? 'pill review'
|
|
535
|
+
: 'pill processing'
|
|
536
|
+
}
|
|
537
|
+
title={badge.tooltip}
|
|
538
|
+
data-go-publish={item.goPublish?.state}
|
|
539
|
+
style={{ flex: 'none', fontSize: 9, padding: '1px 5px', whiteSpace: 'nowrap' }}
|
|
540
|
+
>
|
|
541
|
+
<span className="pdot" />
|
|
542
|
+
{badge.label}
|
|
543
|
+
</span>
|
|
544
|
+
)}
|
|
523
545
|
{statusIcon &&
|
|
524
546
|
(item.status === 'scheduled' ? (
|
|
525
547
|
<button
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// Types
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
+
import type { CalendarGoPublish } from '../../routes/calendar.js'
|
|
17
|
+
|
|
16
18
|
/**
|
|
17
19
|
* A dated content item as returned by the `/admin/api/calendar?from=&to=` route.
|
|
18
20
|
* `date` is an ISO-8601 string (date-only or with time component, UTC-assumed).
|
|
@@ -24,6 +26,8 @@ export interface CalendarItem {
|
|
|
24
26
|
slug: string
|
|
25
27
|
title: string
|
|
26
28
|
date: string
|
|
29
|
+
/** Go publish state of a scheduled video/podcast item (W1-08b); see go-publish-badge.ts. */
|
|
30
|
+
goPublish?: CalendarGoPublish | null
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** A leading blank cell before the first day of the month. */
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/ui/screens/go-publish-badge.ts — pure badge model for a scheduled media
|
|
2
|
+
// item's Go publish state (W1-08b).
|
|
3
|
+
//
|
|
4
|
+
// goPublishBadge(goPublish) → { label, pill, tooltip } | null
|
|
5
|
+
// label — the word the chip shows ("Captured", "Processing", …)
|
|
6
|
+
// pill — the FoundryPillState bucket the broadsheet pill classes render
|
|
7
|
+
// (working / ready / failed), reused from foundry-stages
|
|
8
|
+
// tooltip — the hover text: label, the newest receipt and its time, the
|
|
9
|
+
// correlation id, and the blocked reason when there is one
|
|
10
|
+
//
|
|
11
|
+
// Pure — no DOM, no React, fully vitest-testable.
|
|
12
|
+
|
|
13
|
+
import type { CalendarGoPublish } from '../../routes/calendar.js'
|
|
14
|
+
import type { FoundryPillState } from '../inspector/foundry-stages.js'
|
|
15
|
+
|
|
16
|
+
export interface GoPublishBadge {
|
|
17
|
+
label: string
|
|
18
|
+
pill: FoundryPillState
|
|
19
|
+
tooltip: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const LABELS: Record<string, { label: string; pill: FoundryPillState }> = {
|
|
23
|
+
pending: { label: 'Not captured', pill: 'working' },
|
|
24
|
+
captured: { label: 'Captured', pill: 'working' },
|
|
25
|
+
processing: { label: 'Processing', pill: 'working' },
|
|
26
|
+
media_ready: { label: 'Media ready', pill: 'ready' },
|
|
27
|
+
publishing: { label: 'Publishing', pill: 'working' },
|
|
28
|
+
published: { label: 'Published', pill: 'ready' },
|
|
29
|
+
late: { label: 'Late', pill: 'failed' },
|
|
30
|
+
failed: { label: 'Blocked', pill: 'failed' },
|
|
31
|
+
unavailable: { label: 'State unavailable', pill: 'failed' },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Format a unix-second receipt time for the tooltip (UTC, to the minute). */
|
|
35
|
+
export function formatReceiptTime(unixSeconds: number): string {
|
|
36
|
+
return `${new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function goPublishBadge(
|
|
40
|
+
goPublish: CalendarGoPublish | null | undefined,
|
|
41
|
+
): GoPublishBadge | null {
|
|
42
|
+
if (!goPublish) return null
|
|
43
|
+
const entry = LABELS[goPublish.state] ?? { label: goPublish.state, pill: 'working' as const }
|
|
44
|
+
const lines = [`Go publish: ${entry.label}`]
|
|
45
|
+
if (goPublish.eventKind && goPublish.receiptAt !== null) {
|
|
46
|
+
lines.push(`Last receipt: ${goPublish.eventKind} at ${formatReceiptTime(goPublish.receiptAt)}`)
|
|
47
|
+
} else if (goPublish.eventKind) {
|
|
48
|
+
lines.push(`Last receipt: ${goPublish.eventKind}`)
|
|
49
|
+
} else {
|
|
50
|
+
lines.push('No receipt yet')
|
|
51
|
+
}
|
|
52
|
+
if (goPublish.correlationId) lines.push(`Correlation: ${goPublish.correlationId}`)
|
|
53
|
+
if (goPublish.blockedReason) lines.push(`Reason: ${goPublish.blockedReason}`)
|
|
54
|
+
return { label: entry.label, pill: entry.pill, tooltip: lines.join('\n') }
|
|
55
|
+
}
|