@growth-labs/cms 0.5.30 → 0.5.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/engine/foundry-dispatch.d.ts +7 -2
  2. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  3. package/dist/engine/foundry-dispatch.js +24 -6
  4. package/dist/engine/foundry-dispatch.js.map +1 -1
  5. package/dist/engine/fronts-publish-intent.d.ts +174 -0
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
  7. package/dist/engine/fronts-publish-intent.js +352 -0
  8. package/dist/engine/fronts-publish-intent.js.map +1 -0
  9. package/dist/engine/fronts-publish.d.ts +80 -0
  10. package/dist/engine/fronts-publish.d.ts.map +1 -0
  11. package/dist/engine/fronts-publish.js +286 -0
  12. package/dist/engine/fronts-publish.js.map +1 -0
  13. package/dist/engine/index.d.ts +2 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +5 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publisher.d.ts.map +1 -1
  18. package/dist/engine/publisher.js +21 -1
  19. package/dist/engine/publisher.js.map +1 -1
  20. package/dist/integration/index.d.ts.map +1 -1
  21. package/dist/integration/index.js +10 -0
  22. package/dist/integration/index.js.map +1 -1
  23. package/dist/routes/fronts-publish.d.ts +10 -0
  24. package/dist/routes/fronts-publish.d.ts.map +1 -0
  25. package/dist/routes/fronts-publish.js +250 -0
  26. package/dist/routes/fronts-publish.js.map +1 -0
  27. package/dist/routes/index.d.ts +3 -0
  28. package/dist/routes/index.d.ts.map +1 -1
  29. package/dist/routes/index.js +4 -0
  30. package/dist/routes/index.js.map +1 -1
  31. package/dist/schema/layout.d.ts +2 -2
  32. package/dist/schema/migrations.d.ts.map +1 -1
  33. package/dist/schema/migrations.js +36 -0
  34. package/dist/schema/migrations.js.map +1 -1
  35. package/dist/schema/portable-text.d.ts +2 -2
  36. package/dist/schema/tables.d.ts +1 -1
  37. package/dist/schema/tables.d.ts.map +1 -1
  38. package/dist/schema/tables.js +2 -0
  39. package/dist/schema/tables.js.map +1 -1
  40. package/dist/schema/types.d.ts +59 -0
  41. package/dist/schema/types.d.ts.map +1 -1
  42. package/dist/schema/types.js.map +1 -1
  43. package/dist/surveys/schema.d.ts +54 -54
  44. package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
  45. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
  46. package/dist/ui/api/fronts/publish-callback.js +8 -0
  47. package/dist/ui/api/fronts/publish-callback.js.map +1 -0
  48. package/dist/ui/api/fronts/schedule.d.ts +3 -0
  49. package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
  50. package/dist/ui/api/fronts/schedule.js +8 -0
  51. package/dist/ui/api/fronts/schedule.js.map +1 -0
  52. package/migrations/0026_fronts_publish.sql +31 -0
  53. package/migrations/0027_fronts_publish_intent.sql +44 -0
  54. package/package.json +1 -1
  55. package/src/engine/foundry-dispatch.ts +22 -6
  56. package/src/engine/fronts-publish-intent.ts +676 -0
  57. package/src/engine/fronts-publish.ts +417 -0
  58. package/src/engine/index.ts +27 -0
  59. package/src/engine/publisher.ts +21 -1
  60. package/src/integration/index.ts +10 -0
  61. package/src/routes/fronts-publish.ts +292 -0
  62. package/src/routes/index.ts +8 -0
  63. package/src/schema/migrations.ts +36 -0
  64. package/src/schema/tables.ts +2 -0
  65. package/src/schema/types.ts +61 -0
  66. package/src/ui/api/fronts/publish-callback.ts +18 -0
  67. package/src/ui/api/fronts/schedule.ts +18 -0
@@ -0,0 +1,292 @@
1
+ // Fronts publish routes (cms/Masthead side) — the two machine-to-machine seams
2
+ // the out-of-process Fronts publish worker (foundryd) drives:
3
+ //
4
+ // GET /admin/api/fronts/schedule?window=<from>..<to> — the poll surface.
5
+ // POST /admin/api/fronts/publish-callback — the callback receiver.
6
+ //
7
+ // AUTH reuses the existing M2M primitive (`verifyHmac` + `X-Foundry-Signature`
8
+ // against `ctx.env.FOUNDRY_HMAC_SECRET` — a per-Worker secret string OR a Secrets
9
+ // Store binding `{ get() }`; no new auth scheme, no shared secret in code/D1),
10
+ // hardened against replay with a REQUIRED `X-Foundry-Timestamp`:
11
+ //
12
+ // X-Foundry-Timestamp: <unix seconds, within ±300s of now>
13
+ // X-Foundry-Signature: sha256=HMAC(secret, `${timestamp}\n${payload}`)
14
+ //
15
+ // where payload is the raw query string (`url.search`) for the GET poll and the
16
+ // raw body for the POST callback. Binding the signature to a fresh timestamp
17
+ // stops replay: without it a no-`window` GET would sign HMAC(secret,"") — a
18
+ // CONSTANT — so one captured URL+header pair would be a permanent bearer
19
+ // credential for the pre-publication embargo schedule.
20
+ //
21
+ // Both endpoints ship ENABLED behind FRONTS_PUBLISH_ENABLED (a rollback lever,
22
+ // default ON). Failures are loud: bad/stale HMAC → 401 + warn log; a `live`
23
+ // callback that cannot commit through the ledger → 500 + error log; disabled → 503.
24
+
25
+ import { z } from 'zod'
26
+ import { verifyHmac } from '../engine/foundry-dispatch.js'
27
+ import {
28
+ applyFrontsPublishCallback,
29
+ isFrontsPublishEnabled,
30
+ listFrontsPublishSchedule,
31
+ } from '../engine/fronts-publish.js'
32
+ import { hydrateFrontsPublishIntents } from '../engine/fronts-publish-intent.js'
33
+ import { publicUrlFor } from '../ui/screens/public-url.js'
34
+ import { type CmsRouteConfig, resolveConfig } from './config.js'
35
+ import { json, type RouteContext } from './context.js'
36
+
37
+ /**
38
+ * Resolve the Foundry HMAC secret from ctx.env.FOUNDRY_HMAC_SECRET — the SAME
39
+ * binding the existing foundry-callback route uses. Supports a plain string
40
+ * (test injection / per-Worker secret) or a Secrets Store binding `{ get() }`.
41
+ * Returns null → the route answers 500 "not configured".
42
+ */
43
+ async function resolveFoundryHmacSecret(
44
+ env: Record<string, unknown> | undefined,
45
+ ): Promise<string | null> {
46
+ const raw = env?.FOUNDRY_HMAC_SECRET
47
+ if (typeof raw === 'string') return raw || null
48
+ if (raw && typeof (raw as { get?: unknown }).get === 'function') {
49
+ try {
50
+ const val = await (raw as { get(): Promise<string> }).get()
51
+ return typeof val === 'string' && val.length > 0 ? val : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ /** Max clock skew (seconds) tolerated on X-Foundry-Timestamp — the replay window. */
60
+ const SIGNATURE_MAX_SKEW_SECONDS = 300
61
+
62
+ /** Parse the X-Foundry-Timestamp header (strict unix epoch seconds) or null. */
63
+ function parseTimestampSeconds(raw: string): number | null {
64
+ const trimmed = raw.trim()
65
+ // Strict integer seconds so the exact header bytes are what foundryd signs.
66
+ if (!/^\d{1,15}$/.test(trimmed)) return null
67
+ const seconds = Number.parseInt(trimmed, 10)
68
+ return Number.isSafeInteger(seconds) ? seconds : null
69
+ }
70
+
71
+ /**
72
+ * Verify a foundryd-signed M2M request: a fresh `X-Foundry-Timestamp` (unix
73
+ * seconds within ±{@link SIGNATURE_MAX_SKEW_SECONDS}) plus `X-Foundry-Signature`
74
+ * = HMAC over `${timestamp}\n${payload}`. Returns null when authentic, or a 401
75
+ * Response (with a loud warn log) to short-circuit the handler.
76
+ */
77
+ async function verifyFoundrySignedRequest(
78
+ request: Request,
79
+ secret: string,
80
+ payload: string,
81
+ label: string,
82
+ ): Promise<Response | null> {
83
+ const tsRaw = (request.headers.get('X-Foundry-Timestamp') ?? '').trim()
84
+ const ts = parseTimestampSeconds(tsRaw)
85
+ if (ts === null) {
86
+ console.warn(`[cms] ${label} rejected: missing or invalid X-Foundry-Timestamp`)
87
+ return json({ error: 'Missing or invalid X-Foundry-Timestamp' }, 401)
88
+ }
89
+ const now = Math.floor(Date.now() / 1_000)
90
+ if (Math.abs(now - ts) > SIGNATURE_MAX_SKEW_SECONDS) {
91
+ console.warn(`[cms] ${label} rejected: stale X-Foundry-Timestamp (skew ${now - ts}s)`)
92
+ return json({ error: 'Stale or future X-Foundry-Timestamp' }, 401)
93
+ }
94
+ const signature = request.headers.get('X-Foundry-Signature') ?? ''
95
+ const valid = await verifyHmac(secret, `${tsRaw}\n${payload}`, signature)
96
+ if (!valid) {
97
+ console.warn(`[cms] ${label} rejected: invalid X-Foundry-Signature`)
98
+ return json({ error: 'Invalid signature' }, 401)
99
+ }
100
+ return null
101
+ }
102
+
103
+ const ReceiptSchema = z.object({
104
+ published_urls: z.array(z.string()).nullish(),
105
+ platform_post_ids: z.array(z.string()).nullish(),
106
+ sha256: z.string().nullish(),
107
+ })
108
+
109
+ const CallbackSchema = z
110
+ .object({
111
+ event_id: z.string().min(1).optional(),
112
+ intent_id: z.string().min(1).nullish(),
113
+ content_id: z.string().min(1),
114
+ version: z.number().int().nullish(),
115
+ correlation_id: z.string().min(1).nullish(),
116
+ status: z.enum(['queued', 'publishing', 'live', 'late', 'failed']),
117
+ occurred_at: z.union([z.string(), z.number()]).nullish(),
118
+ receipt: ReceiptSchema.nullish(),
119
+ error: z.string().nullish(),
120
+ })
121
+ .strip()
122
+
123
+ export interface FrontsPublishRouteHandlers {
124
+ /** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
125
+ schedule(ctx: RouteContext): Promise<Response>
126
+ /** POST /fronts/publish-callback — foundryd's outbound lifecycle callback. */
127
+ publishCallback(ctx: RouteContext): Promise<Response>
128
+ }
129
+
130
+ /** Parse `window=<from>..<to>` into unix-seconds bounds; defaults to now..now+1h. */
131
+ function parseWindow(
132
+ value: string | null,
133
+ nowSeconds: number,
134
+ ): { fromSeconds: number; toSeconds: number } | { error: string } {
135
+ if (!value) return { fromSeconds: nowSeconds, toSeconds: nowSeconds + 3600 }
136
+ const parts = value.split('..')
137
+ if (parts.length !== 2) return { error: 'window must be "<from>..<to>"' }
138
+ const parseBound = (raw: string): number | null => {
139
+ const trimmed = raw.trim()
140
+ if (!trimmed) return null
141
+ if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10)
142
+ const ms = Date.parse(trimmed)
143
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1_000)
144
+ }
145
+ const fromSeconds = parseBound(parts[0])
146
+ const toSeconds = parseBound(parts[1])
147
+ if (fromSeconds === null || toSeconds === null) {
148
+ return { error: 'window bounds must be ISO-8601 timestamps or unix seconds' }
149
+ }
150
+ if (toSeconds < fromSeconds) return { error: 'window end precedes its start' }
151
+ return { fromSeconds, toSeconds }
152
+ }
153
+
154
+ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublishRouteHandlers {
155
+ const resolved = resolveConfig(config)
156
+ return {
157
+ async schedule(ctx) {
158
+ const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
159
+ if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
160
+
161
+ const url = new URL(ctx.request.url)
162
+ // The GET poll has no body; the payload is the exact query string
163
+ // foundryd sent (leading '?' included, or '' when empty), bound to the
164
+ // required timestamp so an empty-window poll can't sign a constant.
165
+ const denied = await verifyFoundrySignedRequest(
166
+ ctx.request,
167
+ hmacSecret,
168
+ url.search,
169
+ 'fronts/schedule',
170
+ )
171
+ if (denied) return denied
172
+
173
+ if (!isFrontsPublishEnabled(ctx.env)) {
174
+ return json({ error: 'fronts_publish_disabled' }, 503)
175
+ }
176
+
177
+ const now = Math.floor(Date.now() / 1_000)
178
+ const window = parseWindow(url.searchParams.get('window'), now)
179
+ if ('error' in window) return json({ error: window.error }, 400)
180
+
181
+ // `items` is the UNCHANGED 0026 legacy schedule shape — an existing
182
+ // consumer keeps working byte-for-byte. `intents` is the canonical
183
+ // publish-intent contract (packages#500) hydrated over the SAME due
184
+ // predicate, and `incomplete` names every due item that cannot form a
185
+ // complete intent, so a missing canonical field is an explicit, visible
186
+ // contract error rather than a silently short `intents` array.
187
+ const items = await listFrontsPublishSchedule(ctx.db, {
188
+ toSeconds: window.toSeconds,
189
+ nowSeconds: now,
190
+ })
191
+ const hydrated = await hydrateFrontsPublishIntents(ctx.db, {
192
+ toSeconds: window.toSeconds,
193
+ nowSeconds: now,
194
+ mediaPublicDomain: resolved.mediaPublicDomain,
195
+ // Site-RELATIVE paths: the CMS does not know the site origin, and the
196
+ // consumer joins its own trusted configured origin. A host-supplied
197
+ // origin is deliberately deferred (packages-docs/cms.md).
198
+ resolveContentUrl: (row) => publicUrlFor(row),
199
+ })
200
+ return json({
201
+ ok: true,
202
+ now,
203
+ window: { from: window.fromSeconds, to: window.toSeconds },
204
+ count: items.length,
205
+ items,
206
+ intents: hydrated.intents,
207
+ incomplete: hydrated.incomplete,
208
+ })
209
+ },
210
+
211
+ async publishCallback(ctx) {
212
+ const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
213
+ if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
214
+
215
+ let rawBody: string
216
+ try {
217
+ rawBody = await ctx.request.text()
218
+ } catch {
219
+ return json({ error: 'Failed to read request body' }, 400)
220
+ }
221
+
222
+ const denied = await verifyFoundrySignedRequest(
223
+ ctx.request,
224
+ hmacSecret,
225
+ rawBody,
226
+ 'fronts/publish-callback',
227
+ )
228
+ if (denied) return denied
229
+
230
+ if (!isFrontsPublishEnabled(ctx.env)) {
231
+ return json({ error: 'fronts_publish_disabled' }, 503)
232
+ }
233
+
234
+ let raw: unknown
235
+ try {
236
+ raw = JSON.parse(rawBody)
237
+ } catch {
238
+ return json({ error: 'Invalid JSON body' }, 400)
239
+ }
240
+ const parsed = CallbackSchema.safeParse(raw)
241
+ if (!parsed.success) {
242
+ return json({ error: 'Invalid payload', details: parsed.error.flatten() }, 400)
243
+ }
244
+
245
+ // Idempotency key: body event_id, else the Idempotency-Key header.
246
+ const eventId = parsed.data.event_id ?? ctx.request.headers.get('Idempotency-Key') ?? null
247
+ if (!eventId) {
248
+ return json({ error: 'Missing event_id / Idempotency-Key' }, 400)
249
+ }
250
+
251
+ const result = await applyFrontsPublishCallback(ctx.db, {
252
+ eventId,
253
+ intentId: parsed.data.intent_id ?? null,
254
+ contentId: parsed.data.content_id,
255
+ version: parsed.data.version ?? null,
256
+ correlationId: parsed.data.correlation_id ?? null,
257
+ status: parsed.data.status,
258
+ occurredAt: parsed.data.occurred_at ?? null,
259
+ receipt: parsed.data.receipt ?? null,
260
+ error: parsed.data.error ?? null,
261
+ })
262
+
263
+ if (!result.contentFound) {
264
+ return json({ error: 'content_not_found', contentId: parsed.data.content_id }, 404)
265
+ }
266
+ if (result.ledgerError) {
267
+ // A confirmed `live` that could not be committed through the ledger is a
268
+ // hard, loud failure — never a silent zero.
269
+ console.error(
270
+ `[cms] fronts/publish-callback ledger commit failed for content ${parsed.data.content_id}: ${result.ledgerError}`,
271
+ )
272
+ return json(
273
+ {
274
+ ok: false,
275
+ error: 'ledger_publish_failed',
276
+ detail: result.ledgerError,
277
+ status: result.status,
278
+ },
279
+ 500,
280
+ )
281
+ }
282
+
283
+ return json({
284
+ ok: true,
285
+ deduped: result.deduped,
286
+ status: result.status,
287
+ published: result.published,
288
+ attemptId: result.attemptId,
289
+ })
290
+ },
291
+ }
292
+ }
@@ -100,6 +100,11 @@ export {
100
100
  type DashboardRouteConfig,
101
101
  type DashboardRouteHandlers,
102
102
  } from './dashboard.js'
103
+ // Fronts publish surface — the HMAC schedule poll + publish-callback receiver.
104
+ export {
105
+ createFrontsPublishRoutes,
106
+ type FrontsPublishRouteHandlers,
107
+ } from './fronts-publish.js'
103
108
  // Import routes — two-step parse→confirm pipeline.
104
109
  export { createImportRoutes, type ImportRouteHandlers } from './imports.js'
105
110
  // Media routes.
@@ -198,6 +203,7 @@ import {
198
203
  } from './content-insights.js'
199
204
  import { type CronRouteHandlers, createCronRoutes } from './cron.js'
200
205
  import { createDashboardRoutes, type DashboardRouteHandlers } from './dashboard.js'
206
+ import { createFrontsPublishRoutes, type FrontsPublishRouteHandlers } from './fronts-publish.js'
201
207
  import { createImportRoutes, type ImportRouteHandlers } from './imports.js'
202
208
  import { createMediaRoutes, type MediaRouteHandlers } from './media.js'
203
209
  import { createPreviewRoutes, type PreviewRouteHandlers } from './preview.js'
@@ -231,6 +237,7 @@ export interface CmsRoutes {
231
237
  apiKeys: ApiKeyRouteHandlers
232
238
  webhooks: WebhookRouteHandlers
233
239
  contentInsights: ContentInsightsRouteHandlers
240
+ frontsPublish: FrontsPublishRouteHandlers
234
241
  }
235
242
 
236
243
  /**
@@ -297,5 +304,6 @@ export function createCmsRoutes(
297
304
  authz: config.authz,
298
305
  activeWorkspaceId: config.activeWorkspaceId,
299
306
  }),
307
+ frontsPublish: createFrontsPublishRoutes(base),
300
308
  }
301
309
  }
@@ -841,6 +841,42 @@ ALTER TABLE article_content
841
841
  ADD COLUMN layout_json TEXT;
842
842
  `,
843
843
  },
844
+ {
845
+ id: '0026_fronts_publish',
846
+ sql: `
847
+ ALTER TABLE content_items
848
+ ADD COLUMN fronts_publish_status TEXT
849
+ CHECK (fronts_publish_status IS NULL OR fronts_publish_status IN ('queued','publishing','live','late','failed'));
850
+ CREATE TABLE fronts_publish_events (
851
+ event_id TEXT PRIMARY KEY,
852
+ intent_id TEXT,
853
+ content_id TEXT NOT NULL,
854
+ version INTEGER,
855
+ correlation_id TEXT,
856
+ status TEXT NOT NULL CHECK (status IN ('queued','publishing','live','late','failed')),
857
+ occurred_at INTEGER,
858
+ receipt_json TEXT,
859
+ error TEXT,
860
+ published INTEGER NOT NULL DEFAULT 0,
861
+ attempt_id TEXT,
862
+ received_at INTEGER NOT NULL DEFAULT (unixepoch()),
863
+ FOREIGN KEY (content_id) REFERENCES content_items(id) ON DELETE CASCADE
864
+ );
865
+ CREATE INDEX idx_fronts_publish_events_content ON fronts_publish_events(content_id, received_at DESC);
866
+ CREATE INDEX idx_fronts_publish_events_intent ON fronts_publish_events(intent_id);
867
+ `,
868
+ },
869
+ {
870
+ id: '0027_fronts_publish_intent',
871
+ sql: `
872
+ ALTER TABLE content_items
873
+ ADD COLUMN canonical_version INTEGER NOT NULL DEFAULT 1;
874
+ ALTER TABLE content_items
875
+ ADD COLUMN fronts_channel_targets TEXT;
876
+ ALTER TABLE content_items
877
+ ADD COLUMN fronts_publish_trigger_token TEXT;
878
+ `,
879
+ },
844
880
  ]
845
881
 
846
882
  /**
@@ -43,6 +43,8 @@ export const CMS_TABLES = [
43
43
  'content_insights',
44
44
  'content_insight_runs',
45
45
  'content_insight_dismissals',
46
+ // Fronts publish-callback receiver log (migration 0026)
47
+ 'fronts_publish_events',
46
48
  ] as const
47
49
 
48
50
  export type CmsTableName = (typeof CMS_TABLES)[number]
@@ -20,6 +20,18 @@ export type ContentStatus = 'draft' | 'scheduled' | 'review' | 'published' | 'ar
20
20
  /** content_items.visibility CHECK domain. */
21
21
  export type ContentVisibility = 'free' | 'premium'
22
22
 
23
+ /**
24
+ * content_items.fronts_publish_status CHECK domain (migration 0026).
25
+ *
26
+ * The lifecycle of an item handed to the out-of-process Fronts publish worker
27
+ * (foundryd): it acknowledges the intent (`queued`), begins pushing to the
28
+ * external surfaces (`publishing`), confirms every surface is live (`live`),
29
+ * misses its target window (`late`), or gives up (`failed`). Only `live` (with a
30
+ * receipt) flips the item to `published`. NULL means the item was never routed
31
+ * through the additive Fronts publish path (the untouched D1 cron path owns it).
32
+ */
33
+ export type FrontsPublishStatus = 'queued' | 'publishing' | 'live' | 'late' | 'failed'
34
+
23
35
  /** video_content.processing_state CHECK domain (bead 08 state machine). */
24
36
  export type VideoProcessingState =
25
37
  | 'pending'
@@ -76,6 +88,55 @@ export interface ContentItemRow {
76
88
  metadata_json: string
77
89
  /** Optional namespaced source key used for idempotent archive imports (migration 0020). */
78
90
  source_id: string | null
91
+ /**
92
+ * Lifecycle marker for the additive out-of-process Fronts publish path
93
+ * (migration 0026). NULL for items the legacy D1 cron path owns.
94
+ */
95
+ fronts_publish_status: FrontsPublishStatus | null
96
+ /**
97
+ * Monotonic integer revision of this item's canonical content (migration
98
+ * 0027). 1 on create; advanced by `updateContentItem` only. This is the
99
+ * `version` component of `intent_id = masthead:<content_id>:<version>`, so it
100
+ * is deliberately NOT advanced by `fronts_publish_status` marker writes or by
101
+ * a schedule poll.
102
+ */
103
+ canonical_version: number
104
+ /**
105
+ * JSON array of Fronts publish target slugs (migration 0027), or NULL when
106
+ * the item has not been armed for the out-of-process publish path. NULL is a
107
+ * loud hydration error, never an empty target list. Distinct from `channel`,
108
+ * which is the single editorial Fronts channel slug.
109
+ */
110
+ fronts_channel_targets: string | null
111
+ /**
112
+ * Raw CMS-owned per-item publish trigger token (migration 0027), secret at
113
+ * rest like `video_content.processing_trigger_token`. Never leaves the
114
+ * package: the publish-intent contract exposes only `sha256:<hex>` of it.
115
+ */
116
+ fronts_publish_trigger_token: string | null
117
+ }
118
+
119
+ /**
120
+ * Durable receiver log for foundryd's outbound publish callbacks (migration
121
+ * 0026). One row per `event_id` — the idempotency key. `receipt_json` stores the
122
+ * platform receipt (published_urls / platform_post_ids / sha256); `attempt_id`
123
+ * links to the `content_publication_attempts` 2PC ledger row when a confirmed
124
+ * `live` callback drove the publish.
125
+ */
126
+ export interface FrontsPublishEventRow {
127
+ event_id: string
128
+ intent_id: string | null
129
+ content_id: string
130
+ version: number | null
131
+ correlation_id: string | null
132
+ status: FrontsPublishStatus
133
+ occurred_at: number | null
134
+ receipt_json: string | null
135
+ error: string | null
136
+ /** 1 when this callback flipped (or confirmed) the item to published. */
137
+ published: number
138
+ attempt_id: string | null
139
+ received_at: number
79
140
  }
80
141
 
81
142
  /** Standalone topic taxonomy row. Counts are derived from content_items.primary_topic. */
@@ -0,0 +1,18 @@
1
+ // src/ui/api/fronts/publish-callback.ts
2
+ // POST /admin/api/fronts/publish-callback — receiver for foundryd's outbound
3
+ // publish-lifecycle callbacks. Idempotent by event_id / Idempotency-Key. On a
4
+ // confirmed `live` (with a receipt) it drives the item to published through the
5
+ // content_publication_attempts 2PC ledger. HMAC-SHA256 (X-Foundry-Signature vs
6
+ // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher.
7
+ import type { APIContext } from 'astro'
8
+ import type { RouteContext } from '../../../routes/context.js'
9
+ import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.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
+ export const POST = (c: APIContext) =>
18
+ createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).publishCallback(toCtx(c))
@@ -0,0 +1,18 @@
1
+ // src/ui/api/fronts/schedule.ts
2
+ // GET /admin/api/fronts/schedule?window=<from>..<to> — the machine-to-machine
3
+ // poll surface for the out-of-process Fronts publish worker (foundryd). Returns
4
+ // `scheduled` items due for publishing. HMAC-SHA256 (X-Foundry-Signature vs
5
+ // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher. Sibling of the
6
+ // content foundry-callback / insights-ingest M2M routes.
7
+ import type { APIContext } from 'astro'
8
+ import type { RouteContext } from '../../../routes/context.js'
9
+ import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.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
+ export const GET = (c: APIContext) =>
18
+ createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).schedule(toCtx(c))