@growth-labs/cms 0.8.30 → 0.8.33

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 (45) hide show
  1. package/README.md +20 -0
  2. package/dist/engine/content-metadata.d.ts +2 -0
  3. package/dist/engine/content-metadata.d.ts.map +1 -1
  4. package/dist/engine/content-metadata.js +15 -0
  5. package/dist/engine/content-metadata.js.map +1 -1
  6. package/dist/engine/publication-current-guard.d.ts +3 -0
  7. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  8. package/dist/engine/publication-current-guard.js +19 -4
  9. package/dist/engine/publication-current-guard.js.map +1 -1
  10. package/dist/engine/publication.d.ts +3 -1
  11. package/dist/engine/publication.d.ts.map +1 -1
  12. package/dist/engine/publication.js +34 -5
  13. package/dist/engine/publication.js.map +1 -1
  14. package/dist/engine/publisher.d.ts +8 -8
  15. package/dist/engine/publisher.d.ts.map +1 -1
  16. package/dist/engine/publisher.js +27 -1
  17. package/dist/engine/publisher.js.map +1 -1
  18. package/dist/foundry-emit.d.ts +5 -0
  19. package/dist/foundry-emit.d.ts.map +1 -1
  20. package/dist/foundry-emit.js +3 -0
  21. package/dist/foundry-emit.js.map +1 -1
  22. package/dist/integration/options.d.ts +14 -14
  23. package/dist/routes/content.d.ts.map +1 -1
  24. package/dist/routes/content.js +1 -15
  25. package/dist/routes/content.js.map +1 -1
  26. package/dist/routes/foundry-publish.d.ts.map +1 -1
  27. package/dist/routes/foundry-publish.js +47 -20
  28. package/dist/routes/foundry-publish.js.map +1 -1
  29. package/dist/ui/api/content/insights-ingest.d.ts.map +1 -1
  30. package/dist/ui/api/content/insights-ingest.js +12 -3
  31. package/dist/ui/api/content/insights-ingest.js.map +1 -1
  32. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  33. package/dist/ui/api/content/insights-inputs.js +3 -2
  34. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  35. package/docs/foundry-publish.md +6 -0
  36. package/package.json +1 -1
  37. package/src/engine/content-metadata.ts +17 -0
  38. package/src/engine/publication-current-guard.ts +28 -4
  39. package/src/engine/publication.ts +43 -6
  40. package/src/engine/publisher.ts +46 -1
  41. package/src/foundry-emit.ts +3 -0
  42. package/src/routes/content.ts +1 -20
  43. package/src/routes/foundry-publish.ts +49 -26
  44. package/src/ui/api/content/insights-ingest.ts +10 -3
  45. package/src/ui/api/content/insights-inputs.ts +3 -2
@@ -1,8 +1,9 @@
1
1
  import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/foundry-signing'
2
2
  import { z } from 'zod'
3
3
  import { type PublicationReconciler, publishOne } from '../engine/publication.js'
4
+ import { PublicationCurrentSnapshotError } from '../engine/publication-current-guard.js'
4
5
  import { PublishContentValidationError } from '../engine/publisher.js'
5
- import { createRevisionWithDelta } from '../engine/revisions.js'
6
+ import { createRevisionWithDelta, getRevision } from '../engine/revisions.js'
6
7
  import { buildContentPath, slugify } from '../engine/slug.js'
7
8
  import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.js'
8
9
  import { type ContentRouteConfig, createContentRoutes } from './content.js'
@@ -20,6 +21,10 @@ const PutSchema = z
20
21
  })
21
22
  .strict()
22
23
 
24
+ const PinnedPublishSchema = z
25
+ .object({ expected_request_sha256: z.string().regex(/^[a-f0-9]{64}$/) })
26
+ .strict()
27
+
23
28
  interface FoundryArticleRow {
24
29
  external_id: string
25
30
  content_id: string
@@ -491,6 +496,8 @@ async function bindAdoption(
491
496
  }
492
497
 
493
498
  function publishFailure(id: string, error: unknown): Response {
499
+ if (error instanceof PublicationCurrentSnapshotError)
500
+ return json({ error: 'human_edit_pending', external_id: id }, 409)
494
501
  if (error instanceof PublishContentValidationError) {
495
502
  return json({ error: 'publish_validation_failed', external_id: id, errors: error.errors }, 422)
496
503
  }
@@ -545,29 +552,23 @@ async function recordFoundryWrite(
545
552
  }
546
553
  }
547
554
 
548
- /** True while the working copy is still exactly what Foundry's last v2 PUT wrote. */
549
- async function lastFoundryWriteCurrent(
555
+ /** The immutable last PUT snapshot, only while its canonical version is current. */
556
+ async function lastFoundryWriteSnapshot(
550
557
  ctx: RouteContext,
551
558
  row: FoundryArticleRow,
552
- ): Promise<boolean> {
553
- const versions = await ctx.db
554
- .prepare(
555
- `SELECT c.canonical_version AS current_version,
556
- (SELECT json_extract(r.payload_json, '$.item.canonical_version')
557
- FROM content_revisions r
558
- WHERE r.content_id = c.id AND r.created_by = 'foundry' AND r.tag = ?
559
- ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1) AS put_version
560
- FROM content_items c
561
- WHERE c.id = ? AND c.deleted_at IS NULL
562
- LIMIT 1`,
563
- )
564
- .bind(`${FOUNDRY_PUT_TAG}${row.request_sha256}`, row.content_id)
565
- .first<{ current_version: number | null; put_version: number | null }>()
566
- return (
567
- versions !== null &&
568
- versions.put_version !== null &&
569
- versions.current_version === versions.put_version
570
- )
559
+ ): Promise<Record<string, unknown> | null> {
560
+ const match = await ctx.db
561
+ .prepare(`SELECT r.id AS revision_id
562
+ FROM content_items c JOIN content_revisions r ON r.content_id = c.id
563
+ WHERE c.id = ? AND c.deleted_at IS NULL AND r.created_by = 'foundry' AND r.tag = ?
564
+ AND c.canonical_version IS NOT NULL
565
+ AND c.canonical_version = json_extract(r.payload_json, '$.item.canonical_version')
566
+ ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1`)
567
+ .bind(row.content_id, `${FOUNDRY_PUT_TAG}${row.request_sha256}`)
568
+ .first<{ revision_id: string }>()
569
+ if (!match) return null
570
+ const revision = await getRevision(ctx.db, row.content_id, match.revision_id)
571
+ return revision ? { ...revision.payload } : null
571
572
  }
572
573
 
573
574
  /** The v2 receipt: the four v1 fields plus the stored request hash. */
@@ -671,6 +672,7 @@ async function putV2(
671
672
  ...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
672
673
  ...(body.tags !== undefined ? { tags: body.tags } : {}),
673
674
  ...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
675
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
674
676
  content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
675
677
  }
676
678
 
@@ -776,7 +778,8 @@ async function publishV2(
776
778
  // Publish, schedule and resync alike act on the working copy only while it is
777
779
  // still exactly what Foundry's last v2 PUT wrote: a human edit since then (to
778
780
  // a draft or a published item) is never machine-published or machine-scheduled.
779
- if (!(await lastFoundryWriteCurrent(ctx, row))) {
781
+ const expectedSnapshot = await lastFoundryWriteSnapshot(ctx, row)
782
+ if (!expectedSnapshot) {
780
783
  return json({ error: 'human_edit_pending', external_id: id }, 409)
781
784
  }
782
785
 
@@ -832,6 +835,8 @@ async function publishV2(
832
835
  now: input.now,
833
836
  publishedAt,
834
837
  createdBy: 'foundry',
838
+ expectedFoundryArticleSnapshot: expectedSnapshot,
839
+ expectedReceiptIdentity: row.request_sha256,
835
840
  surfaces,
836
841
  ...(config.foundryPublish?.requiredSurfaceNames
837
842
  ? { requiredSurfaceNames: config.foundryPublish.requiredSurfaceNames }
@@ -1059,14 +1064,32 @@ export function createFoundryPublishRoutes(
1059
1064
  const rawBody = await ctx.request.text()
1060
1065
  const auth = await verify(ctx, rawBody)
1061
1066
  if ('error' in auth) return auth.error
1062
- // POST /publish takes no body: anything sent is refused, never ignored.
1063
- if (rawBody.length > 0 || !/^0?$/.test(ctx.request.headers.get('Content-Length') ?? '')) {
1064
- return json({ error: 'invalid_request', detail: 'publish takes no request body' }, 400)
1067
+ // Legacy callers publish the current write. Strategy execution pins the
1068
+ // exact PUT bytes in the signed body; an unrelated replacement is refused.
1069
+ let expectedHash: string | undefined
1070
+ if (rawBody.length > 0) {
1071
+ let value: unknown
1072
+ try {
1073
+ value = JSON.parse(rawBody)
1074
+ } catch {
1075
+ return json({ error: 'invalid_request' }, 400)
1076
+ }
1077
+ const parsed = PinnedPublishSchema.safeParse(value)
1078
+ if (!parsed.success) return json({ error: 'invalid_request' }, 400)
1079
+ expectedHash = parsed.data.expected_request_sha256
1080
+ } else if (!/^0?$/.test(ctx.request.headers.get('Content-Length') ?? '')) {
1081
+ return json(
1082
+ { error: 'invalid_request', detail: 'empty publish request declares a body' },
1083
+ 400,
1084
+ )
1065
1085
  }
1066
1086
  const row = await findArticle(ctx, id)
1067
1087
  if (!row) return json({ error: 'not_found' }, 404)
1068
1088
  // Without the publication's opt-in the machine route stays draft-only.
1069
1089
  if (!autoPublishEnabled(config)) return approvalConflict(id, row.status)
1090
+ if (expectedHash !== undefined && expectedHash !== row.request_sha256) {
1091
+ return json({ error: 'article_revision_changed', external_id: id }, 409)
1092
+ }
1070
1093
  return publishV2(ctx, config, content, id, row)
1071
1094
  },
1072
1095
  }
@@ -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) ??