@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.
@@ -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
- * Replay-safe replace of the board for (meta.site_id, meta.run_date).
109
- * - 'duplicate': meta.event_id already present in content_insight_runs.event_id → no-op.
110
- * - 'stale': an equal-or-higher run_attempt already landed for (site_id, run_date) → no-op
111
- * (HMAC authenticates the sender; run_attempt is the monotonic replay guard).
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
- * The INSERT carries 16 columns (id, site_id, run_id, run_date, desk, suggestion_type,
118
- * signal_type, is_best_bet, best_bet_rank, desk_rank, headline, evidence, brief,
119
- * intent_key, beat, created_at) matching migration 0016 column order.
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
- // 3) Scoped replace + bulk insert + run upsert in one D1 batch.
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('DELETE FROM content_insights WHERE site_id = ? AND run_date = ?')
151
- .bind(meta.site_id, meta.run_date),
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
- `INSERT INTO content_insights
161
- (id, site_id, run_id, run_date, desk, suggestion_type, signal_type, is_best_bet,
162
- best_bet_rank, desk_rank, headline, evidence, brief, intent_key, beat, created_at)
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
- // Upsert the run row keyed on UNIQUE (site_id, run_date).
218
+ // This MUST be the final mutation: preceding row writes share its eligibility.
186
219
  statements.push(
187
220
  db
188
- .prepare(
189
- `INSERT INTO content_insight_runs
190
- (id, site_id, run_date, status, event_id, run_attempt, generated_at, content_hash,
191
- demand_count, opportunity_count, started_at, completed_at, error)
192
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
193
- ON CONFLICT(site_id, run_date) DO UPDATE SET
194
- status = excluded.status,
195
- event_id = excluded.event_id,
196
- run_attempt = excluded.run_attempt,
197
- generated_at = excluded.generated_at,
198
- content_hash = excluded.content_hash,
199
- demand_count = excluded.demand_count,
200
- opportunity_count = excluded.opportunity_count,
201
- completed_at = excluded.completed_at,
202
- error = excluded.error`,
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
- existing?.id ?? crypto.randomUUID(),
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
- await db.batch(statements)
222
- return { outcome: 'applied', landedRunAttempt: meta.run_attempt }
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 (isMissingInsightsTable(error)) return json({ error: 'schema_not_ready' }, 409)
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
  },
@@ -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
- // Only the onboarded share host is claimed, and only the share-link shape:
403
- // https, no credentials, default port, /s/<token> with an optional /download.
404
- // Nextcloud's folder-listing "Copy link" appends ?dir=/ to a single-file share;
405
- // it carries nothing the download route needs, so it is dropped. Anything else
406
- // -- another host, another path, a non-URL -- is returned untouched for the
407
- // normal URL validation to accept or reject on its own terms.
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(url.hostname.toLowerCase().replace(/\.$/, '')) ||
424
- !NEXTCLOUD_SHARE_PATH_RE.test(url.pathname)
436
+ !NEXTCLOUD_SHARE_HOSTS.includes(host) ||
437
+ url.hash
425
438
  ) {
426
439
  return value
427
440
  }
428
- if (url.searchParams.get('dir') !== null && url.searchParams.size === 1) {
429
- url.search = ''
430
- }
431
- if (!/\/download\/?$/.test(url.pathname)) {
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
- return url.toString()
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
@@ -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
  /**
@@ -677,7 +677,7 @@ export interface ContentInsightRunRow {
677
677
  site_id: string
678
678
  run_date: string
679
679
  status: InsightRunStatus
680
- /** Dedupe key (mirrors foundry_callback_events.event_id). */
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