@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.
- package/dist/engine/foundry-dispatch.d.ts +7 -2
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +24 -6
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +174 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
- package/dist/engine/fronts-publish-intent.js +352 -0
- package/dist/engine/fronts-publish-intent.js.map +1 -0
- package/dist/engine/fronts-publish.d.ts +80 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -0
- package/dist/engine/fronts-publish.js +286 -0
- package/dist/engine/fronts-publish.js.map +1 -0
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +5 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +21 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +10 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +10 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -0
- package/dist/routes/fronts-publish.js +250 -0
- package/dist/routes/fronts-publish.js.map +1 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/schema/layout.d.ts +2 -2
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +36 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/portable-text.d.ts +2 -2
- 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/schema/types.d.ts +59 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/schema.d.ts +54 -54
- package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
- package/dist/ui/api/fronts/publish-callback.js +8 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -0
- package/dist/ui/api/fronts/schedule.d.ts +3 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
- package/dist/ui/api/fronts/schedule.js +8 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -0
- package/migrations/0026_fronts_publish.sql +31 -0
- package/migrations/0027_fronts_publish_intent.sql +44 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +22 -6
- package/src/engine/fronts-publish-intent.ts +676 -0
- package/src/engine/fronts-publish.ts +417 -0
- package/src/engine/index.ts +27 -0
- package/src/engine/publisher.ts +21 -1
- package/src/integration/index.ts +10 -0
- package/src/routes/fronts-publish.ts +292 -0
- package/src/routes/index.ts +8 -0
- package/src/schema/migrations.ts +36 -0
- package/src/schema/tables.ts +2 -0
- package/src/schema/types.ts +61 -0
- package/src/ui/api/fronts/publish-callback.ts +18 -0
- 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
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/schema/migrations.ts
CHANGED
|
@@ -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
|
/**
|
package/src/schema/tables.ts
CHANGED
|
@@ -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]
|
package/src/schema/types.ts
CHANGED
|
@@ -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))
|