@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.
- package/README.md +20 -0
- package/dist/engine/content-metadata.d.ts +2 -0
- package/dist/engine/content-metadata.d.ts.map +1 -1
- package/dist/engine/content-metadata.js +15 -0
- package/dist/engine/content-metadata.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +3 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +19 -4
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publication.d.ts +3 -1
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +34 -5
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts +8 -8
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +27 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/foundry-emit.d.ts +5 -0
- package/dist/foundry-emit.d.ts.map +1 -1
- package/dist/foundry-emit.js +3 -0
- package/dist/foundry-emit.js.map +1 -1
- package/dist/integration/options.d.ts +14 -14
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +1 -15
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +47 -20
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/ui/api/content/insights-ingest.d.ts.map +1 -1
- package/dist/ui/api/content/insights-ingest.js +12 -3
- package/dist/ui/api/content/insights-ingest.js.map +1 -1
- package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
- package/dist/ui/api/content/insights-inputs.js +3 -2
- package/dist/ui/api/content/insights-inputs.js.map +1 -1
- package/docs/foundry-publish.md +6 -0
- package/package.json +1 -1
- package/src/engine/content-metadata.ts +17 -0
- package/src/engine/publication-current-guard.ts +28 -4
- package/src/engine/publication.ts +43 -6
- package/src/engine/publisher.ts +46 -1
- package/src/foundry-emit.ts +3 -0
- package/src/routes/content.ts +1 -20
- package/src/routes/foundry-publish.ts +49 -26
- package/src/ui/api/content/insights-ingest.ts +10 -3
- 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
|
-
/**
|
|
549
|
-
async function
|
|
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<
|
|
553
|
-
const
|
|
554
|
-
.prepare(
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1063
|
-
|
|
1064
|
-
|
|
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 =
|
|
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 =
|
|
17
|
-
return (
|
|
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 =
|
|
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 =
|
|
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) ??
|