@growth-labs/cms 0.8.17 → 0.8.19
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/README.md +1 -1
- package/dist/engine/content-insights.d.ts +27 -11
- package/dist/engine/content-insights.d.ts.map +1 -1
- package/dist/engine/content-insights.js +102 -53
- package/dist/engine/content-insights.js.map +1 -1
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +5 -2
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +29 -15
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +4 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0032_content_insight_publication_identity.sql +3 -0
- package/package.json +1 -1
- package/src/engine/content-insights.ts +142 -63
- package/src/routes/content-insights.ts +6 -1
- package/src/routes/content.ts +29 -15
- package/src/schema/migrations.ts +7 -0
- package/src/schema/types.ts +4 -1
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
InsightEvidence,
|
|
7
7
|
} from '../schema/types.js'
|
|
8
8
|
import type { D1Database } from './d1.js'
|
|
9
|
+
import { canonicalJsonSha256, type JsonValue } from './publication-receipt/canonical-json.js'
|
|
9
10
|
|
|
10
11
|
/** True when an Insights read/write depends on a table missing from an older schema. */
|
|
11
12
|
export function isMissingInsightsTable(error: unknown): boolean {
|
|
@@ -99,69 +100,100 @@ export async function undismissInsight(
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
export type ReplaceOutcome = 'applied' | 'duplicate' | 'stale'
|
|
103
|
+
|
|
104
|
+
/** Identity read from the committed run row, never echoed from the incoming request. */
|
|
105
|
+
export interface InsightPublicationIdentity {
|
|
106
|
+
eventId: string
|
|
107
|
+
siteId: string
|
|
108
|
+
runDate: string
|
|
109
|
+
runAttempt: number
|
|
110
|
+
runId: string | null
|
|
111
|
+
contentHash: string
|
|
112
|
+
/** RFC 8785 SHA-256 of the full validated {meta, rows}, including actual row content. */
|
|
113
|
+
payloadSha256: string | null
|
|
114
|
+
/** Legacy rows have no digest/run identity and cannot confirm a duplicate. */
|
|
115
|
+
verified: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
export interface ReplaceResult {
|
|
103
119
|
outcome: ReplaceOutcome
|
|
104
120
|
landedRunAttempt: number
|
|
121
|
+
publication: InsightPublicationIdentity
|
|
105
122
|
}
|
|
106
123
|
|
|
124
|
+
export class InsightPublicationError extends Error {
|
|
125
|
+
constructor(readonly code: 'publication_identity_conflict' | 'publication_identity_unverified') {
|
|
126
|
+
super(code)
|
|
127
|
+
this.name = 'InsightPublicationError'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Only ingest references these columns; do not mask unrelated missing-column failures. */
|
|
132
|
+
export function isMissingInsightPublicationSchema(error: unknown): boolean {
|
|
133
|
+
return (
|
|
134
|
+
isMissingInsightsTable(error) ||
|
|
135
|
+
(error instanceof Error &&
|
|
136
|
+
/(?:no such table: content_insight_runs\b|no such column:\s*(?:publication_run_id|payload_sha256)\b|table\s+content_insight_runs\s+has no column named\s+(?:publication_run_id|payload_sha256)\b)/i.test(
|
|
137
|
+
error.message,
|
|
138
|
+
))
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface PublicationRow {
|
|
143
|
+
event_id: string
|
|
144
|
+
site_id: string
|
|
145
|
+
run_date: string
|
|
146
|
+
run_attempt: number
|
|
147
|
+
publication_run_id: string | null
|
|
148
|
+
content_hash: string
|
|
149
|
+
payload_sha256: string | null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const publicationColumns = `event_id, site_id, run_date, run_attempt,
|
|
153
|
+
publication_run_id, content_hash, payload_sha256`
|
|
154
|
+
|
|
107
155
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* - 'applied': DELETE the existing board scoped by (site_id, run_date), bulk-insert rows
|
|
113
|
-
* (mint id, run_id = meta.run_id, beat from payload, created_at = unixepoch()),
|
|
114
|
-
* upsert the run row.
|
|
115
|
-
* Dismissals are untouched — they live in their own table keyed (site_id, intent_key).
|
|
156
|
+
* Every eligibility read, guarded write and identity read executes in ONE D1 batch.
|
|
157
|
+
* The run table stays unchanged until the final mutation, so every DELETE/INSERT
|
|
158
|
+
* evaluates the same predicate against the same transaction state. D1 rolls back
|
|
159
|
+
* the entire batch on any failure. Dismissals are never touched.
|
|
116
160
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
161
|
+
* Current event IDs are immutable: exact replay is a no-op; divergent or legacy
|
|
162
|
+
* unverified reuse fails without writes. Once superseded, an old event has no
|
|
163
|
+
* historical dedupe promise; its original attempt remains stale.
|
|
120
164
|
*/
|
|
121
165
|
export async function replaceInsightsBoard(
|
|
122
166
|
db: D1Database,
|
|
123
167
|
payload: InsightsIngestPayload,
|
|
124
168
|
): Promise<ReplaceResult> {
|
|
125
169
|
const { meta, rows } = payload
|
|
126
|
-
|
|
127
|
-
// 1) event_id dedupe (mirrors foundry_callback_events).
|
|
128
|
-
const dupEvent = await db
|
|
129
|
-
.prepare('SELECT id FROM content_insight_runs WHERE event_id = ? LIMIT 1')
|
|
130
|
-
.bind(meta.event_id)
|
|
131
|
-
.first<{ id: string }>()
|
|
132
|
-
if (dupEvent) return { outcome: 'duplicate', landedRunAttempt: meta.run_attempt }
|
|
133
|
-
|
|
134
|
-
// 2) stale-replay guard: reject equal-or-lower run_attempt for (site_id, run_date).
|
|
135
|
-
const existing = await db
|
|
136
|
-
.prepare(
|
|
137
|
-
'SELECT id, run_attempt FROM content_insight_runs WHERE site_id = ? AND run_date = ? LIMIT 1',
|
|
138
|
-
)
|
|
139
|
-
.bind(meta.site_id, meta.run_date)
|
|
140
|
-
.first<{ id: string; run_attempt: number }>()
|
|
141
|
-
if (existing && meta.run_attempt <= existing.run_attempt) {
|
|
142
|
-
return { outcome: 'stale', landedRunAttempt: existing.run_attempt }
|
|
143
|
-
}
|
|
144
|
-
|
|
170
|
+
const payloadSha256 = await canonicalJsonSha256(payload as unknown as JsonValue)
|
|
145
171
|
const now = Math.floor(Date.now() / 1000)
|
|
146
172
|
|
|
147
|
-
|
|
173
|
+
const eligible = `NOT EXISTS (SELECT 1 FROM content_insight_runs WHERE event_id = ?)
|
|
174
|
+
AND NOT EXISTS (SELECT 1 FROM content_insight_runs
|
|
175
|
+
WHERE site_id = ? AND run_date = ? AND run_attempt >= ?)`
|
|
176
|
+
const eligibilityBindings = [meta.event_id, meta.site_id, meta.run_date, meta.run_attempt]
|
|
177
|
+
const currentPublication = db
|
|
178
|
+
.prepare(`SELECT ${publicationColumns} FROM content_insight_runs
|
|
179
|
+
WHERE site_id = ? AND run_date = ?`)
|
|
180
|
+
.bind(meta.site_id, meta.run_date)
|
|
148
181
|
const statements = [
|
|
149
182
|
db
|
|
150
|
-
.prepare(
|
|
151
|
-
.bind(meta.
|
|
183
|
+
.prepare(`SELECT ${publicationColumns} FROM content_insight_runs WHERE event_id = ? LIMIT 2`)
|
|
184
|
+
.bind(meta.event_id),
|
|
185
|
+
currentPublication,
|
|
186
|
+
db
|
|
187
|
+
.prepare(`DELETE FROM content_insights WHERE site_id = ? AND run_date = ? AND ${eligible}`)
|
|
188
|
+
.bind(meta.site_id, meta.run_date, ...eligibilityBindings),
|
|
152
189
|
]
|
|
153
190
|
for (const row of rows) {
|
|
154
|
-
// 16 columns: id, site_id, run_id, run_date, desk, suggestion_type, signal_type,
|
|
155
|
-
// is_best_bet, best_bet_rank, desk_rank, headline, evidence, brief,
|
|
156
|
-
// intent_key, beat, created_at
|
|
157
191
|
statements.push(
|
|
158
192
|
db
|
|
159
|
-
.prepare(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
164
|
-
)
|
|
193
|
+
.prepare(`INSERT INTO content_insights
|
|
194
|
+
(id, site_id, run_id, run_date, desk, suggestion_type, signal_type, is_best_bet,
|
|
195
|
+
best_bet_rank, desk_rank, headline, evidence, brief, intent_key, beat, created_at)
|
|
196
|
+
SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,? WHERE ${eligible}`)
|
|
165
197
|
.bind(
|
|
166
198
|
crypto.randomUUID(),
|
|
167
199
|
row.site_id,
|
|
@@ -179,30 +211,32 @@ export async function replaceInsightsBoard(
|
|
|
179
211
|
row.intent_key,
|
|
180
212
|
row.beat,
|
|
181
213
|
now,
|
|
214
|
+
...eligibilityBindings,
|
|
182
215
|
),
|
|
183
216
|
)
|
|
184
217
|
}
|
|
185
|
-
//
|
|
218
|
+
// This MUST be the final mutation: preceding row writes share its eligibility.
|
|
186
219
|
statements.push(
|
|
187
220
|
db
|
|
188
|
-
.prepare(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
221
|
+
.prepare(`INSERT INTO content_insight_runs
|
|
222
|
+
(id, site_id, run_date, status, event_id, run_attempt, generated_at, content_hash,
|
|
223
|
+
demand_count, opportunity_count, started_at, completed_at, error,
|
|
224
|
+
publication_run_id, payload_sha256)
|
|
225
|
+
SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,? WHERE ${eligible}
|
|
226
|
+
ON CONFLICT(site_id, run_date) DO UPDATE SET
|
|
227
|
+
status = excluded.status,
|
|
228
|
+
event_id = excluded.event_id,
|
|
229
|
+
run_attempt = excluded.run_attempt,
|
|
230
|
+
generated_at = excluded.generated_at,
|
|
231
|
+
content_hash = excluded.content_hash,
|
|
232
|
+
demand_count = excluded.demand_count,
|
|
233
|
+
opportunity_count = excluded.opportunity_count,
|
|
234
|
+
completed_at = excluded.completed_at,
|
|
235
|
+
error = excluded.error,
|
|
236
|
+
publication_run_id = excluded.publication_run_id,
|
|
237
|
+
payload_sha256 = excluded.payload_sha256`)
|
|
204
238
|
.bind(
|
|
205
|
-
|
|
239
|
+
crypto.randomUUID(),
|
|
206
240
|
meta.site_id,
|
|
207
241
|
meta.run_date,
|
|
208
242
|
meta.status,
|
|
@@ -215,9 +249,54 @@ export async function replaceInsightsBoard(
|
|
|
215
249
|
now,
|
|
216
250
|
meta.status === 'completed' || meta.status === 'partial' ? now : null,
|
|
217
251
|
meta.error ?? null,
|
|
252
|
+
meta.run_id,
|
|
253
|
+
payloadSha256,
|
|
254
|
+
...eligibilityBindings,
|
|
218
255
|
),
|
|
256
|
+
currentPublication,
|
|
219
257
|
)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
258
|
+
const results = await db.batch(statements)
|
|
259
|
+
const events = (results[0].results ?? []) as unknown as PublicationRow[]
|
|
260
|
+
const previous = results[1].results?.[0] as unknown as PublicationRow | undefined
|
|
261
|
+
if (events.length > 0) {
|
|
262
|
+
const event = events[0]
|
|
263
|
+
if (
|
|
264
|
+
events.length !== 1 ||
|
|
265
|
+
event.site_id !== meta.site_id ||
|
|
266
|
+
event.run_date !== meta.run_date ||
|
|
267
|
+
event.run_attempt !== meta.run_attempt ||
|
|
268
|
+
event.content_hash !== meta.content_hash ||
|
|
269
|
+
(event.publication_run_id !== null && event.publication_run_id !== meta.run_id)
|
|
270
|
+
) {
|
|
271
|
+
throw new InsightPublicationError('publication_identity_conflict')
|
|
272
|
+
}
|
|
273
|
+
if (event.payload_sha256 === null || event.publication_run_id === null) {
|
|
274
|
+
throw new InsightPublicationError('publication_identity_unverified')
|
|
275
|
+
}
|
|
276
|
+
if (event.payload_sha256 !== payloadSha256) {
|
|
277
|
+
throw new InsightPublicationError('publication_identity_conflict')
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const committed = results[results.length - 1].results?.[0] as unknown as
|
|
281
|
+
| PublicationRow
|
|
282
|
+
| undefined
|
|
283
|
+
if (!committed) throw new Error('Missing committed insight publication')
|
|
284
|
+
return {
|
|
285
|
+
outcome: events.length
|
|
286
|
+
? 'duplicate'
|
|
287
|
+
: previous && previous.run_attempt >= meta.run_attempt
|
|
288
|
+
? 'stale'
|
|
289
|
+
: 'applied',
|
|
290
|
+
landedRunAttempt: committed.run_attempt,
|
|
291
|
+
publication: {
|
|
292
|
+
eventId: committed.event_id,
|
|
293
|
+
siteId: committed.site_id,
|
|
294
|
+
runDate: committed.run_date,
|
|
295
|
+
runAttempt: committed.run_attempt,
|
|
296
|
+
runId: committed.publication_run_id,
|
|
297
|
+
contentHash: committed.content_hash,
|
|
298
|
+
payloadSha256: committed.payload_sha256,
|
|
299
|
+
verified: committed.payload_sha256 !== null && committed.publication_run_id !== null,
|
|
300
|
+
},
|
|
301
|
+
}
|
|
223
302
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import {
|
|
3
3
|
dismissInsight,
|
|
4
|
+
InsightPublicationError,
|
|
4
5
|
isMissingInsightDismissalSchema,
|
|
6
|
+
isMissingInsightPublicationSchema,
|
|
5
7
|
isMissingInsightsTable,
|
|
6
8
|
listInsightBoard,
|
|
7
9
|
replaceInsightsBoard,
|
|
@@ -203,9 +205,12 @@ export function createContentInsightsRoutes(
|
|
|
203
205
|
ok: true,
|
|
204
206
|
outcome: result.outcome,
|
|
205
207
|
landedRunAttempt: result.landedRunAttempt,
|
|
208
|
+
publication: result.publication,
|
|
206
209
|
})
|
|
207
210
|
} catch (error) {
|
|
208
|
-
if (
|
|
211
|
+
if (isMissingInsightPublicationSchema(error))
|
|
212
|
+
return json({ error: 'schema_not_ready' }, 409)
|
|
213
|
+
if (error instanceof InsightPublicationError) return json({ error: error.code }, 409)
|
|
209
214
|
throw error
|
|
210
215
|
}
|
|
211
216
|
},
|
package/src/routes/content.ts
CHANGED
|
@@ -399,14 +399,26 @@ const ContentMetadataSchema = z
|
|
|
399
399
|
// deploy until 0.8.8, because the masthead only rendered the top-level
|
|
400
400
|
// 'Invalid request' and never the field message telling them what to change.
|
|
401
401
|
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
402
|
+
// The direct form is `https://<host>/public.php/dav/files/<token>`. From 0.8.8
|
|
403
|
+
// to 0.8.17 this produced `/s/<token>/download` instead; on 2026-09-19 that URL
|
|
404
|
+
// began answering HEAD with a 303 to the dav form, which the Go ingest probe does
|
|
405
|
+
// not follow, so every Nextcloud-sourced video blocked at `media-probe-source`
|
|
406
|
+
// (KB incident-uk-video-prep-2026-09-19). Storing the dav form directly means the
|
|
407
|
+
// probe never meets a redirect. A URL already in the dav form is returned as it
|
|
408
|
+
// is, so the function is idempotent, and rows saved earlier with the old
|
|
409
|
+
// `/download` form are not rewritten by a save that does not touch the source.
|
|
410
|
+
//
|
|
411
|
+
// Only the onboarded share host is claimed, and only two shapes: https, no
|
|
412
|
+
// credentials, default port, and either /s/<token> (with an optional /download
|
|
413
|
+
// and trailing slash) or the dav form itself. Nextcloud's folder-listing "Copy
|
|
414
|
+
// link" appends ?dir=/ to a single-file share; it carries nothing the download
|
|
415
|
+
// route needs, so it is dropped. Any other query is a shape this does not know,
|
|
416
|
+
// so the value is returned untouched. Anything else -- another host, another
|
|
417
|
+
// path, a non-URL -- is likewise returned untouched for the normal URL
|
|
418
|
+
// validation to accept or reject on its own terms.
|
|
408
419
|
const NEXTCLOUD_SHARE_HOSTS = ['drive.fulcrum-portal.com']
|
|
409
|
-
const NEXTCLOUD_SHARE_PATH_RE = /^\/s\/[A-Za-z0-9_-]+(?:\/download)?\/?$/
|
|
420
|
+
const NEXTCLOUD_SHARE_PATH_RE = /^\/s\/([A-Za-z0-9_-]+)(?:\/download)?\/?$/
|
|
421
|
+
const NEXTCLOUD_DIRECT_PATH_RE = /^\/public\.php\/dav\/files\/[A-Za-z0-9_-]+$/
|
|
410
422
|
|
|
411
423
|
export function normaliseNextcloudShareUrl(value: string): string {
|
|
412
424
|
let url: URL
|
|
@@ -415,23 +427,25 @@ export function normaliseNextcloudShareUrl(value: string): string {
|
|
|
415
427
|
} catch {
|
|
416
428
|
return value
|
|
417
429
|
}
|
|
430
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, '')
|
|
418
431
|
if (
|
|
419
432
|
url.protocol !== 'https:' ||
|
|
420
433
|
url.username ||
|
|
421
434
|
url.password ||
|
|
422
435
|
(url.port && url.port !== '443') ||
|
|
423
|
-
!NEXTCLOUD_SHARE_HOSTS.includes(
|
|
424
|
-
|
|
436
|
+
!NEXTCLOUD_SHARE_HOSTS.includes(host) ||
|
|
437
|
+
url.hash
|
|
425
438
|
) {
|
|
426
439
|
return value
|
|
427
440
|
}
|
|
428
|
-
|
|
429
|
-
url.search
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
url.pathname = `${url.pathname.replace(/\/$/, '')}/download`
|
|
441
|
+
const onlyDirQuery =
|
|
442
|
+
url.search === '' || (url.searchParams.get('dir') !== null && url.searchParams.size === 1)
|
|
443
|
+
if (NEXTCLOUD_DIRECT_PATH_RE.test(url.pathname)) {
|
|
444
|
+
return url.search === '' ? value : onlyDirQuery ? `https://${host}${url.pathname}` : value
|
|
433
445
|
}
|
|
434
|
-
|
|
446
|
+
const share = NEXTCLOUD_SHARE_PATH_RE.exec(url.pathname)
|
|
447
|
+
if (!share || !onlyDirQuery) return value
|
|
448
|
+
return `https://${host}/public.php/dav/files/${share[1]}`
|
|
435
449
|
}
|
|
436
450
|
|
|
437
451
|
// A Google Drive link is not run through the Nextcloud rewrite; it is kept as
|
package/src/schema/migrations.ts
CHANGED
|
@@ -914,6 +914,13 @@ ALTER TABLE podcast_content ADD COLUMN processing_state TEXT CHECK (processing_s
|
|
|
914
914
|
id: '0031_podcast_processing_error',
|
|
915
915
|
sql: `ALTER TABLE podcast_content ADD COLUMN processing_error TEXT;`,
|
|
916
916
|
},
|
|
917
|
+
{
|
|
918
|
+
id: '0032_content_insight_publication_identity',
|
|
919
|
+
sql: `
|
|
920
|
+
ALTER TABLE content_insight_runs ADD COLUMN publication_run_id TEXT;
|
|
921
|
+
ALTER TABLE content_insight_runs ADD COLUMN payload_sha256 TEXT;
|
|
922
|
+
`,
|
|
923
|
+
},
|
|
917
924
|
]
|
|
918
925
|
|
|
919
926
|
/**
|
package/src/schema/types.ts
CHANGED
|
@@ -677,7 +677,7 @@ export interface ContentInsightRunRow {
|
|
|
677
677
|
site_id: string
|
|
678
678
|
run_date: string
|
|
679
679
|
status: InsightRunStatus
|
|
680
|
-
/**
|
|
680
|
+
/** Immutable event identity while this publication remains current. */
|
|
681
681
|
event_id: string
|
|
682
682
|
/** Monotonic; the stale-replay guard rejects equal-or-lower. */
|
|
683
683
|
run_attempt: number
|
|
@@ -685,6 +685,9 @@ export interface ContentInsightRunRow {
|
|
|
685
685
|
generated_at: number
|
|
686
686
|
/** Content hash of the board for change detection. */
|
|
687
687
|
content_hash: string
|
|
688
|
+
/** Sender run identity and receiver-computed RFC 8785 payload digest; null on legacy rows. */
|
|
689
|
+
publication_run_id: string | null
|
|
690
|
+
payload_sha256: string | null
|
|
688
691
|
demand_count: number
|
|
689
692
|
opportunity_count: number
|
|
690
693
|
started_at: number | null
|