@growth-labs/cms 0.8.28 → 0.8.32

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/content-insights.d.ts +27 -3
  2. package/dist/engine/content-insights.d.ts.map +1 -1
  3. package/dist/engine/content-insights.js +83 -4
  4. package/dist/engine/content-insights.js.map +1 -1
  5. package/dist/engine/content-metadata.d.ts +2 -0
  6. package/dist/engine/content-metadata.d.ts.map +1 -1
  7. package/dist/engine/content-metadata.js +15 -0
  8. package/dist/engine/content-metadata.js.map +1 -1
  9. package/dist/engine/index.d.ts +1 -0
  10. package/dist/engine/index.d.ts.map +1 -1
  11. package/dist/engine/index.js +1 -0
  12. package/dist/engine/index.js.map +1 -1
  13. package/dist/engine/publication-receipt/receipt.d.ts +4 -4
  14. package/dist/engine/publication.d.ts.map +1 -1
  15. package/dist/engine/publication.js +23 -3
  16. package/dist/engine/publication.js.map +1 -1
  17. package/dist/engine/publisher.d.ts +8 -8
  18. package/dist/engine/publisher.d.ts.map +1 -1
  19. package/dist/engine/publisher.js +27 -1
  20. package/dist/engine/publisher.js.map +1 -1
  21. package/dist/foundry-emit.d.ts +5 -0
  22. package/dist/foundry-emit.d.ts.map +1 -1
  23. package/dist/foundry-emit.js +3 -0
  24. package/dist/foundry-emit.js.map +1 -1
  25. package/dist/integration/options.d.ts +14 -14
  26. package/dist/routes/content-insights.d.ts.map +1 -1
  27. package/dist/routes/content-insights.js +33 -3
  28. package/dist/routes/content-insights.js.map +1 -1
  29. package/dist/routes/content.d.ts.map +1 -1
  30. package/dist/routes/content.js +1 -15
  31. package/dist/routes/content.js.map +1 -1
  32. package/dist/routes/foundry-publish.d.ts.map +1 -1
  33. package/dist/routes/foundry-publish.js +1 -0
  34. package/dist/routes/foundry-publish.js.map +1 -1
  35. package/dist/routes/fronts-publish.d.ts +1 -1
  36. package/dist/routes/fronts-publish.d.ts.map +1 -1
  37. package/dist/routes/fronts-publish.js +2 -2
  38. package/dist/routes/fronts-publish.js.map +1 -1
  39. package/dist/schema/insights-ingest.d.ts +897 -33
  40. package/dist/schema/insights-ingest.d.ts.map +1 -1
  41. package/dist/schema/insights-ingest.js +38 -0
  42. package/dist/schema/insights-ingest.js.map +1 -1
  43. package/dist/schema/types.d.ts +20 -0
  44. package/dist/schema/types.d.ts.map +1 -1
  45. package/dist/schema/types.js.map +1 -1
  46. package/dist/ui/api/content/insights-ingest.d.ts.map +1 -1
  47. package/dist/ui/api/content/insights-ingest.js +12 -3
  48. package/dist/ui/api/content/insights-ingest.js.map +1 -1
  49. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  50. package/dist/ui/api/content/insights-inputs.js +3 -2
  51. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  52. package/docs/foundry-publish.md +6 -0
  53. package/package.json +1 -1
  54. package/src/engine/content-insights.ts +99 -6
  55. package/src/engine/content-metadata.ts +17 -0
  56. package/src/engine/index.ts +1 -0
  57. package/src/engine/publication.ts +26 -2
  58. package/src/engine/publisher.ts +46 -1
  59. package/src/foundry-emit.ts +3 -0
  60. package/src/routes/content-insights.ts +40 -2
  61. package/src/routes/content.ts +1 -20
  62. package/src/routes/foundry-publish.ts +1 -0
  63. package/src/routes/fronts-publish.ts +2 -1
  64. package/src/schema/insights-ingest.ts +45 -0
  65. package/src/schema/types.ts +10 -0
  66. package/src/ui/api/content/insights-ingest.ts +10 -3
  67. package/src/ui/api/content/insights-inputs.ts +3 -2
@@ -1665,13 +1665,58 @@ END`
1665
1665
  * the second disjunct keeps unpublish→republish correct, since `unpublishContent`
1666
1666
  * clears `published_at` but the article did go live once.
1667
1667
  */
1668
- export const PUBLISH_METADATA_STAMP_SQL = `metadata_json = CASE
1668
+ const PUBLISH_METADATA_STAMP_EXPRESSION = `CASE
1669
1669
  WHEN published_at IS NOT NULL
1670
1670
  OR json_extract(${METADATA_OBJECT_SQL}, '$.adaptedAt') IS NOT NULL
1671
1671
  THEN json_set(${METADATA_WITH_ADAPTED_AT_SQL}, '$.updatedAt', date(?, 'unixepoch'))
1672
1672
  ELSE ${METADATA_WITH_ADAPTED_AT_SQL}
1673
1673
  END`
1674
1674
 
1675
+ export const PUBLISH_METADATA_STAMP_SQL = `metadata_json = ${PUBLISH_METADATA_STAMP_EXPRESSION}`
1676
+
1677
+ /** Project the same publish stamp onto the immutable snapshot's captured row. */
1678
+ export async function publicationMetadataForSnapshot(
1679
+ db: D1Database,
1680
+ item: {
1681
+ id?: unknown
1682
+ published_revision_id?: unknown
1683
+ metadata_json?: unknown
1684
+ published_at?: unknown
1685
+ created_at?: unknown
1686
+ },
1687
+ now: number,
1688
+ ): Promise<string> {
1689
+ // A draft metadata edit can remove adaptedAt. The previous immutable native
1690
+ // revision retains the actual first publication day; creation time may be
1691
+ // earlier and published_at may be the source video's historical date.
1692
+ let priorAdaptedAt: string | null = null
1693
+ if (typeof item.id === 'string' && typeof item.published_revision_id === 'string') {
1694
+ const prior = await db
1695
+ .prepare(`SELECT
1696
+ json_extract(json_extract(payload_json, '$.item.metadata_json'), '$.adaptedAt') AS adapted_at
1697
+ FROM content_revisions WHERE id = ? AND content_id = ?`)
1698
+ .bind(item.published_revision_id, item.id)
1699
+ .first<{ adapted_at: string | null }>()
1700
+ priorAdaptedAt = prior?.adapted_at ?? null
1701
+ }
1702
+ const projected = await db
1703
+ .prepare(`SELECT ${PUBLISH_METADATA_STAMP_EXPRESSION} AS metadata_json
1704
+ FROM (SELECT CASE WHEN ? IS NOT NULL THEN json_set(COALESCE(?, '{}'), '$.adaptedAt', ?)
1705
+ ELSE ? END AS metadata_json, ? AS published_at, ? AS created_at)`)
1706
+ .bind(
1707
+ ...publishMetadataStampBinds(now),
1708
+ priorAdaptedAt,
1709
+ item.metadata_json ?? null,
1710
+ priorAdaptedAt,
1711
+ item.metadata_json ?? null,
1712
+ item.published_at ?? null,
1713
+ item.created_at ?? null,
1714
+ )
1715
+ .first<{ metadata_json: string }>()
1716
+ if (!projected) throw new Error('publication metadata projection failed')
1717
+ return projected.metadata_json
1718
+ }
1719
+
1675
1720
  /** Placeholder count in {@link PUBLISH_METADATA_STAMP_SQL}, derived so it cannot drift. */
1676
1721
  const PUBLISH_METADATA_STAMP_PLACEHOLDERS = (PUBLISH_METADATA_STAMP_SQL.match(/\?/g) ?? []).length
1677
1722
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { sha256Hex } from '@growth-labs/foundry-signing'
11
11
  import { z } from 'zod'
12
+ import { ContentMetadataSchema } from './engine/content-metadata.js'
12
13
  import type { ContentArchiveItem } from './engine/publication.js'
13
14
  import { slugify } from './engine/slug.js'
14
15
 
@@ -58,6 +59,7 @@ export const FoundryArticleV2Schema = z
58
59
  tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
59
60
  description: z.string().trim().min(1).optional(),
60
61
  visibility: z.enum(['free', 'premium']).optional(),
62
+ metadata: ContentMetadataSchema.optional(),
61
63
  adoptSourceId: z
62
64
  .string()
63
65
  .min(1)
@@ -147,6 +149,7 @@ export function toFoundryArticleV2(
147
149
  ...(item.tags && item.tags.length > 0 ? { tags: [...item.tags] } : {}),
148
150
  ...(description ? { description } : {}),
149
151
  ...(item.visibility ? { visibility: item.visibility } : {}),
152
+ ...(item.metadata != null ? { metadata: item.metadata } : {}),
150
153
  ...(present(item.sourceId) ? { adoptSourceId: item.sourceId } : {}),
151
154
  }
152
155
  return FoundryArticleV2Schema.parse(body)
@@ -6,6 +6,7 @@ import {
6
6
  isMissingInsightPublicationSchema,
7
7
  isMissingInsightsTable,
8
8
  listInsightBoard,
9
+ readSelectedClusterMembership,
9
10
  replaceInsightsBoard,
10
11
  undismissInsight,
11
12
  } from '../engine/content-insights.js'
@@ -312,7 +313,11 @@ export function createContentInsightsRoutes(
312
313
  } catch (error) {
313
314
  if (isMissingInsightPublicationSchema(error))
314
315
  return json({ error: 'schema_not_ready' }, 409)
315
- if (error instanceof InsightPublicationError) return json({ error: error.code }, 409)
316
+ if (error instanceof InsightPublicationError)
317
+ return json(
318
+ { error: error.code },
319
+ error.code === 'selected_membership_replacement_refused' ? 422 : 409,
320
+ )
316
321
  throw error
317
322
  }
318
323
  },
@@ -321,7 +326,13 @@ export function createContentInsightsRoutes(
321
326
  // family as fronts/schedule (timestamp-bound signature over the query
322
327
  // string; a GET has no body to bind freshness to instead).
323
328
  async insightsInputs(ctx) {
324
- const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env)
329
+ // Channel publications use the existing shared CMS binding. An explicit
330
+ // legacy binding retains precedence, including loud failure if unreadable.
331
+ const binding =
332
+ ctx.env?.FOUNDRY_HMAC_SECRET === undefined
333
+ ? 'CMS_FOUNDRY_HMAC_SECRET'
334
+ : 'FOUNDRY_HMAC_SECRET'
335
+ const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env, binding)
325
336
  if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
326
337
 
327
338
  const url = new URL(ctx.request.url)
@@ -344,11 +355,38 @@ export function createContentInsightsRoutes(
344
355
  title: string
345
356
  slug: string
346
357
  }>()
358
+ let selected: Awaited<ReturnType<typeof readSelectedClusterMembership>> | null = null
359
+ // Membership cannot ride the historical requested-site echo: it is
360
+ // returned only for this configured publication's signed request.
361
+ if (siteId !== 'default' && requestedSite === siteId) {
362
+ try {
363
+ selected = await readSelectedClusterMembership(ctx.db, siteId)
364
+ } catch (error) {
365
+ if (
366
+ !(error instanceof Error && error.message.startsWith('cluster_membership_unknown:')) &&
367
+ !isMissingInsightsTable(error)
368
+ )
369
+ throw error
370
+ }
371
+ }
347
372
  return json({
348
373
  ok: true,
349
374
  site: requestedSite ?? siteId,
350
375
  authors: authorsResult.results ?? [],
351
376
  existing: existingResult.results ?? [],
377
+ cluster_membership: selected ? 'selected' : 'unknown',
378
+ cluster_selections:
379
+ selected?.map((membership) => ({
380
+ site_id: membership.site_id,
381
+ intent_key: membership.intent_key,
382
+ desk: membership.desk,
383
+ decision_ref: membership.decision_ref,
384
+ taxonomy_ref: membership.taxonomy_ref,
385
+ hub_content_id: membership.hub.content_id,
386
+ hub_revision_id: membership.hub.revision_id,
387
+ child_content_ids: membership.children.map((page) => page.content_id),
388
+ child_revision_ids: membership.children.map((page) => page.revision_id),
389
+ })) ?? null,
352
390
  })
353
391
  },
354
392
  }
@@ -18,11 +18,7 @@ import { z } from 'zod'
18
18
  import { logActivity } from '../engine/activity-log.js'
19
19
  import { generateContentDek } from '../engine/ai-dek.js'
20
20
  import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
21
- import {
22
- type ContentMetadata,
23
- ContentMetadataError,
24
- serializeContentMetadata,
25
- } from '../engine/content-metadata.js'
21
+ import { ContentMetadataSchema } from '../engine/content-metadata.js'
26
22
  import { listContributors, setContributors } from '../engine/contributors.js'
27
23
  import type { D1Database } from '../engine/d1.js'
28
24
  import {
@@ -410,21 +406,6 @@ const BodyContentCreateSchema = z.object({
410
406
  editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
411
407
  })
412
408
 
413
- const ContentMetadataSchema = z
414
- .record(z.string(), z.unknown())
415
- .superRefine((metadata, ctx) => {
416
- try {
417
- serializeContentMetadata(metadata as ContentMetadata)
418
- } catch (error) {
419
- ctx.addIssue({
420
- code: 'custom',
421
- message:
422
- error instanceof ContentMetadataError ? error.message : 'content metadata is invalid',
423
- })
424
- }
425
- })
426
- .transform((metadata) => metadata as ContentMetadata)
427
-
428
409
  // A Nextcloud share PAGE is HTML, never ingestable media, so the stored source
429
410
  // must be the direct-download URL. Editors paste the share link -- that is what
430
411
  // Nextcloud's "Copy link" button yields -- so normalise it here instead of
@@ -671,6 +671,7 @@ async function putV2(
671
671
  ...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
672
672
  ...(body.tags !== undefined ? { tags: body.tags } : {}),
673
673
  ...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
674
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
674
675
  content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
675
676
  }
676
677
 
@@ -60,8 +60,9 @@ import { json, type RouteContext } from './context.js'
60
60
  // family rather than inventing a second one.
61
61
  export async function resolveFoundryHmacSecret(
62
62
  env: Record<string, unknown> | undefined,
63
+ binding: 'FOUNDRY_HMAC_SECRET' | 'CMS_FOUNDRY_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
63
64
  ): Promise<string | null> {
64
- const raw = env?.FOUNDRY_HMAC_SECRET
65
+ const raw = env?.[binding]
65
66
  if (typeof raw === 'string') return raw || null
66
67
  if (raw && typeof (raw as { get?: unknown }).get === 'function') {
67
68
  try {
@@ -115,12 +115,42 @@ export const InsightCompetitorArticleSchema = z.object({
115
115
  covered: z.boolean().optional(),
116
116
  })
117
117
 
118
+ const PublishedClusterPageSchema = z
119
+ .object({
120
+ content_id: z.string().min(1),
121
+ revision_id: z.string().min(1),
122
+ url: z.string().url(),
123
+ title: z.string().min(1),
124
+ })
125
+ .strict()
126
+
127
+ export const InsightSelectedClusterMembershipSchema = z
128
+ .object({
129
+ site_id: z.string().min(1),
130
+ run_id: z.string().min(1),
131
+ intent_key: z.string().min(1),
132
+ desk: z.string().min(1),
133
+ decision_ref: z.string().min(1),
134
+ taxonomy_ref: z.string().min(1),
135
+ hub: PublishedClusterPageSchema,
136
+ children: z.array(PublishedClusterPageSchema).min(4),
137
+ })
138
+ .strict()
139
+ .refine((membership) => {
140
+ const members = [membership.hub, ...membership.children]
141
+ return (
142
+ new Set(members.map((page) => page.content_id)).size === members.length &&
143
+ new Set(members.map((page) => page.url)).size === members.length
144
+ )
145
+ }, 'selected hub and children must be distinct published pages')
146
+
118
147
  export const InsightBriefSchema = z.object({
119
148
  why_now: z.string().min(1),
120
149
  angle: z.string().min(1),
121
150
  cluster: z.object({
122
151
  canonical: z.string().min(1),
123
152
  spokes: z.array(z.string().min(1)),
153
+ selected: InsightSelectedClusterMembershipSchema.optional(),
124
154
  }),
125
155
  sources: z.array(
126
156
  z.object({
@@ -200,6 +230,21 @@ export const InsightsIngestPayloadSchema = z
200
230
  // replace DELETE by meta.site_id + meta.run_date — never run_date alone).
201
231
  .superRefine((payload, ctx) => {
202
232
  for (const [i, row] of payload.rows.entries()) {
233
+ const selected = row.brief.cluster.selected
234
+ if (
235
+ selected &&
236
+ (selected.site_id !== row.site_id ||
237
+ selected.run_id !== payload.meta.run_id ||
238
+ selected.intent_key !== row.intent_key ||
239
+ selected.desk !== row.desk)
240
+ ) {
241
+ ctx.addIssue({
242
+ code: z.ZodIssueCode.custom,
243
+ path: ['rows', i, 'brief', 'cluster', 'selected'],
244
+ message:
245
+ 'selected membership must bind the same site, run, desk and intent as its signed row',
246
+ })
247
+ }
203
248
  if (row.site_id !== payload.meta.site_id || row.run_date !== payload.meta.run_date) {
204
249
  ctx.addIssue({
205
250
  code: z.ZodIssueCode.custom,
@@ -611,6 +611,16 @@ export interface InsightBrief {
611
611
  cluster: {
612
612
  canonical: string
613
613
  spokes: string[]
614
+ selected?: {
615
+ site_id: string
616
+ run_id: string
617
+ intent_key: string
618
+ desk: string
619
+ decision_ref: string
620
+ taxonomy_ref: string
621
+ hub: { content_id: string; revision_id: string; url: string; title: string }
622
+ children: Array<{ content_id: string; revision_id: string; url: string; title: string }>
623
+ }
614
624
  }
615
625
  sources: Array<{ title: string; url: string }>
616
626
  format?: 'cornerstone' | 'pillar' | 'standalone'
@@ -2,19 +2,26 @@
2
2
  // POST /admin/api/content/insights-ingest — machine ingest (replay-safe replace).
3
3
  // HMAC-SHA256 (X-Foundry-Signature vs env.INSIGHTS_INGEST_HMAC_SECRET) only —
4
4
  // NOT behind requirePublisher. Sibling of foundry-callback under /content/.
5
+ import { env as cloudflareEnv } from 'cloudflare:workers'
6
+ import { config } from 'virtual:growth-labs/cms/config'
5
7
  import type { APIContext } from 'astro'
6
8
  import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
7
9
  import type { RouteContext } from '../../../routes/context.js'
8
10
  import { buildSiteAuthz } from '../_authz.js'
11
+ import { toContentConfig } from '../_content-config.js'
9
12
 
10
13
  function toCtx(c: APIContext): RouteContext {
11
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
+ const env = cloudflareEnv as Record<string, unknown>
12
15
  return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
13
16
  }
14
17
 
15
18
  function siteId(c: APIContext): string {
16
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
17
- return (env.ACTIVE_WORKSPACE_ID as string | undefined) ?? 'default'
19
+ const env = cloudflareEnv as Record<string, unknown>
20
+ return (
21
+ toContentConfig(c).activeWorkspaceId ??
22
+ (env.ACTIVE_WORKSPACE_ID as string | undefined) ??
23
+ (config.workspaces?.length === 1 ? config.workspaces[0]!.id : 'default')
24
+ )
18
25
  }
19
26
 
20
27
  export const POST = (c: APIContext) =>
@@ -5,6 +5,7 @@
5
5
  // foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
6
6
  // requirePublisher. Sibling of insights-ingest under /content/.
7
7
 
8
+ import { env as cloudflareEnv } from 'cloudflare:workers'
8
9
  import { config } from 'virtual:growth-labs/cms/config'
9
10
  import type { APIContext } from 'astro'
10
11
  import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
@@ -13,12 +14,12 @@ import { buildSiteAuthz } from '../_authz.js'
13
14
  import { toContentConfig } from '../_content-config.js'
14
15
 
15
16
  function toCtx(c: APIContext): RouteContext {
16
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
17
+ const env = cloudflareEnv as Record<string, unknown>
17
18
  return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
18
19
  }
19
20
 
20
21
  function siteId(c: APIContext): string {
21
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
22
+ const env = cloudflareEnv as Record<string, unknown>
22
23
  return (
23
24
  toContentConfig(c).activeWorkspaceId ??
24
25
  (env.ACTIVE_WORKSPACE_ID as string | undefined) ??