@growth-labs/cms 0.5.29 → 0.5.31

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 (151) hide show
  1. package/dist/engine/foundry-dispatch.d.ts +7 -2
  2. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  3. package/dist/engine/foundry-dispatch.js +24 -6
  4. package/dist/engine/foundry-dispatch.js.map +1 -1
  5. package/dist/engine/fronts-publish-intent.d.ts +174 -0
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
  7. package/dist/engine/fronts-publish-intent.js +352 -0
  8. package/dist/engine/fronts-publish-intent.js.map +1 -0
  9. package/dist/engine/fronts-publish.d.ts +80 -0
  10. package/dist/engine/fronts-publish.d.ts.map +1 -0
  11. package/dist/engine/fronts-publish.js +286 -0
  12. package/dist/engine/fronts-publish.js.map +1 -0
  13. package/dist/engine/index.d.ts +7 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +10 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publication-continuity.d.ts +77 -0
  18. package/dist/engine/publication-continuity.d.ts.map +1 -0
  19. package/dist/engine/publication-continuity.js +94 -0
  20. package/dist/engine/publication-continuity.js.map +1 -0
  21. package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
  22. package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
  23. package/dist/engine/publication-receipt/canonical-json.js +100 -0
  24. package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
  25. package/dist/engine/publication-receipt/receipt.d.ts +316 -0
  26. package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
  27. package/dist/engine/publication-receipt/receipt.js +131 -0
  28. package/dist/engine/publication-receipt/receipt.js.map +1 -0
  29. package/dist/engine/publication-receipt/signature.d.ts +36 -0
  30. package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
  31. package/dist/engine/publication-receipt/signature.js +79 -0
  32. package/dist/engine/publication-receipt/signature.js.map +1 -0
  33. package/dist/engine/publication.d.ts.map +1 -1
  34. package/dist/engine/publication.js +2 -0
  35. package/dist/engine/publication.js.map +1 -1
  36. package/dist/engine/publisher.d.ts.map +1 -1
  37. package/dist/engine/publisher.js +23 -1
  38. package/dist/engine/publisher.js.map +1 -1
  39. package/dist/engine/source-sections.d.ts +20 -0
  40. package/dist/engine/source-sections.d.ts.map +1 -0
  41. package/dist/engine/source-sections.js +130 -0
  42. package/dist/engine/source-sections.js.map +1 -0
  43. package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
  44. package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
  45. package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
  46. package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
  47. package/dist/engine/validator/index.d.ts +10 -0
  48. package/dist/engine/validator/index.d.ts.map +1 -1
  49. package/dist/engine/validator/index.js +4 -0
  50. package/dist/engine/validator/index.js.map +1 -1
  51. package/dist/engine/validator/types.d.ts +1 -1
  52. package/dist/engine/validator/types.d.ts.map +1 -1
  53. package/dist/engine/validator/types.js +10 -0
  54. package/dist/engine/validator/types.js.map +1 -1
  55. package/dist/integration/index.d.ts.map +1 -1
  56. package/dist/integration/index.js +10 -0
  57. package/dist/integration/index.js.map +1 -1
  58. package/dist/providers/types.d.ts +1 -0
  59. package/dist/providers/types.d.ts.map +1 -1
  60. package/dist/routes/content.d.ts.map +1 -1
  61. package/dist/routes/content.js +22 -2
  62. package/dist/routes/content.js.map +1 -1
  63. package/dist/routes/fronts-publish.d.ts +10 -0
  64. package/dist/routes/fronts-publish.d.ts.map +1 -0
  65. package/dist/routes/fronts-publish.js +250 -0
  66. package/dist/routes/fronts-publish.js.map +1 -0
  67. package/dist/routes/index.d.ts +3 -0
  68. package/dist/routes/index.d.ts.map +1 -1
  69. package/dist/routes/index.js +4 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/surveys.d.ts.map +1 -1
  72. package/dist/routes/surveys.js +13 -1
  73. package/dist/routes/surveys.js.map +1 -1
  74. package/dist/schema/insights-ingest.d.ts +28 -28
  75. package/dist/schema/layout.d.ts +2 -2
  76. package/dist/schema/migrations.d.ts.map +1 -1
  77. package/dist/schema/migrations.js +36 -0
  78. package/dist/schema/migrations.js.map +1 -1
  79. package/dist/schema/portable-text.d.ts +2 -2
  80. package/dist/schema/tables.d.ts +1 -1
  81. package/dist/schema/tables.d.ts.map +1 -1
  82. package/dist/schema/tables.js +2 -0
  83. package/dist/schema/tables.js.map +1 -1
  84. package/dist/schema/types.d.ts +59 -0
  85. package/dist/schema/types.d.ts.map +1 -1
  86. package/dist/schema/types.js.map +1 -1
  87. package/dist/surveys/access.d.ts +2 -0
  88. package/dist/surveys/access.d.ts.map +1 -1
  89. package/dist/surveys/access.js +7 -0
  90. package/dist/surveys/access.js.map +1 -1
  91. package/dist/surveys/errors.d.ts +8 -0
  92. package/dist/surveys/errors.d.ts.map +1 -1
  93. package/dist/surveys/errors.js +12 -0
  94. package/dist/surveys/errors.js.map +1 -1
  95. package/dist/surveys/schema.d.ts +129 -54
  96. package/dist/surveys/schema.d.ts.map +1 -1
  97. package/dist/surveys/schema.js +45 -0
  98. package/dist/surveys/schema.js.map +1 -1
  99. package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
  100. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
  101. package/dist/ui/api/fronts/publish-callback.js +8 -0
  102. package/dist/ui/api/fronts/publish-callback.js.map +1 -0
  103. package/dist/ui/api/fronts/schedule.d.ts +3 -0
  104. package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
  105. package/dist/ui/api/fronts/schedule.js +8 -0
  106. package/dist/ui/api/fronts/schedule.js.map +1 -0
  107. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  108. package/dist/ui/editor/ContentForm.js +52 -1
  109. package/dist/ui/editor/ContentForm.js.map +1 -1
  110. package/dist/ui/editor/body-warnings.d.ts +27 -0
  111. package/dist/ui/editor/body-warnings.d.ts.map +1 -0
  112. package/dist/ui/editor/body-warnings.js +58 -0
  113. package/dist/ui/editor/body-warnings.js.map +1 -0
  114. package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
  115. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  116. package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
  117. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  118. package/migrations/0026_fronts_publish.sql +31 -0
  119. package/migrations/0027_fronts_publish_intent.sql +44 -0
  120. package/package.json +1 -1
  121. package/src/engine/foundry-dispatch.ts +22 -6
  122. package/src/engine/fronts-publish-intent.ts +676 -0
  123. package/src/engine/fronts-publish.ts +417 -0
  124. package/src/engine/index.ts +60 -0
  125. package/src/engine/publication-continuity.ts +140 -0
  126. package/src/engine/publication-receipt/canonical-json.ts +123 -0
  127. package/src/engine/publication-receipt/receipt.ts +160 -0
  128. package/src/engine/publication-receipt/signature.ts +112 -0
  129. package/src/engine/publication.ts +2 -0
  130. package/src/engine/publisher.ts +23 -1
  131. package/src/engine/source-sections.ts +153 -0
  132. package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
  133. package/src/engine/validator/index.ts +12 -0
  134. package/src/engine/validator/types.ts +19 -0
  135. package/src/integration/index.ts +10 -0
  136. package/src/providers/types.ts +1 -1
  137. package/src/routes/content.ts +23 -2
  138. package/src/routes/fronts-publish.ts +292 -0
  139. package/src/routes/index.ts +8 -0
  140. package/src/routes/surveys.ts +12 -1
  141. package/src/schema/migrations.ts +36 -0
  142. package/src/schema/tables.ts +2 -0
  143. package/src/schema/types.ts +61 -0
  144. package/src/surveys/access.ts +9 -0
  145. package/src/surveys/errors.ts +12 -0
  146. package/src/surveys/schema.ts +47 -0
  147. package/src/ui/api/fronts/publish-callback.ts +18 -0
  148. package/src/ui/api/fronts/schedule.ts +18 -0
  149. package/src/ui/editor/ContentForm.tsx +93 -0
  150. package/src/ui/editor/body-warnings.ts +78 -0
  151. package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
@@ -28,9 +28,11 @@ export function createSurveyRoutes(config: SurveyRouteConfig): SurveyRouteHandle
28
28
  if (denied) return withNoStoreAuthFailure(denied)
29
29
  const surveyId = parseSurveyId(ctx)
30
30
  if (!surveyId) return surveyErrorResponse('survey_invalid_request')
31
+ const offset = parseOffset(ctx)
32
+ if (offset === null) return surveyErrorResponse('survey_invalid_request')
31
33
  try {
32
34
  const parsed = SurveyResultsSchema.safeParse(
33
- await providers.surveys?.getResults({ surveyId }),
35
+ await providers.surveys?.getResults({ surveyId, offset }),
34
36
  )
35
37
  if (!parsed.success || parsed.data.survey.id !== surveyId) {
36
38
  return surveyErrorResponse('survey_provider_malformed')
@@ -70,6 +72,13 @@ async function requireSurveyAccess(authz: Authz, ctx: RouteContext): Promise<Res
70
72
  return authz.requireRole('view_survey_responses' satisfies Capability, ctx)
71
73
  }
72
74
 
75
+ function parseOffset(ctx: RouteContext): number | null {
76
+ const raw = new URL(ctx.request.url).searchParams.get('offset')
77
+ if (raw === null) return 0
78
+ const value = Number(raw)
79
+ return Number.isInteger(value) && value >= 0 ? value : null
80
+ }
81
+
73
82
  function parseSurveyId(ctx: RouteContext): string | null {
74
83
  const value = ctx.params.surveyId ?? ctx.params.id
75
84
  const parsed = SurveyIdSchema.safeParse(value)
@@ -103,6 +112,8 @@ const ProviderFailureAllowlist = new Map<string, SurveyErrorCode>([
103
112
  ['404:survey_not_found', 'survey_not_found'],
104
113
  ['500:survey_internal_failure', 'survey_internal_failure'],
105
114
  ['502:survey_upstream_failure', 'survey_upstream_failure'],
115
+ ['502:survey_upstream_auth_failed', 'survey_upstream_auth_failed'],
116
+ ['502:survey_invalid_response', 'survey_invalid_response'],
106
117
  ['503:survey_provider_unavailable', 'survey_provider_unavailable'],
107
118
  ['503:survey_upstream_unavailable', 'survey_upstream_unavailable'],
108
119
  ])
@@ -841,6 +841,42 @@ ALTER TABLE article_content
841
841
  ADD COLUMN layout_json TEXT;
842
842
  `,
843
843
  },
844
+ {
845
+ id: '0026_fronts_publish',
846
+ sql: `
847
+ ALTER TABLE content_items
848
+ ADD COLUMN fronts_publish_status TEXT
849
+ CHECK (fronts_publish_status IS NULL OR fronts_publish_status IN ('queued','publishing','live','late','failed'));
850
+ CREATE TABLE fronts_publish_events (
851
+ event_id TEXT PRIMARY KEY,
852
+ intent_id TEXT,
853
+ content_id TEXT NOT NULL,
854
+ version INTEGER,
855
+ correlation_id TEXT,
856
+ status TEXT NOT NULL CHECK (status IN ('queued','publishing','live','late','failed')),
857
+ occurred_at INTEGER,
858
+ receipt_json TEXT,
859
+ error TEXT,
860
+ published INTEGER NOT NULL DEFAULT 0,
861
+ attempt_id TEXT,
862
+ received_at INTEGER NOT NULL DEFAULT (unixepoch()),
863
+ FOREIGN KEY (content_id) REFERENCES content_items(id) ON DELETE CASCADE
864
+ );
865
+ CREATE INDEX idx_fronts_publish_events_content ON fronts_publish_events(content_id, received_at DESC);
866
+ CREATE INDEX idx_fronts_publish_events_intent ON fronts_publish_events(intent_id);
867
+ `,
868
+ },
869
+ {
870
+ id: '0027_fronts_publish_intent',
871
+ sql: `
872
+ ALTER TABLE content_items
873
+ ADD COLUMN canonical_version INTEGER NOT NULL DEFAULT 1;
874
+ ALTER TABLE content_items
875
+ ADD COLUMN fronts_channel_targets TEXT;
876
+ ALTER TABLE content_items
877
+ ADD COLUMN fronts_publish_trigger_token TEXT;
878
+ `,
879
+ },
844
880
  ]
845
881
 
846
882
  /**
@@ -43,6 +43,8 @@ export const CMS_TABLES = [
43
43
  'content_insights',
44
44
  'content_insight_runs',
45
45
  'content_insight_dismissals',
46
+ // Fronts publish-callback receiver log (migration 0026)
47
+ 'fronts_publish_events',
46
48
  ] as const
47
49
 
48
50
  export type CmsTableName = (typeof CMS_TABLES)[number]
@@ -20,6 +20,18 @@ export type ContentStatus = 'draft' | 'scheduled' | 'review' | 'published' | 'ar
20
20
  /** content_items.visibility CHECK domain. */
21
21
  export type ContentVisibility = 'free' | 'premium'
22
22
 
23
+ /**
24
+ * content_items.fronts_publish_status CHECK domain (migration 0026).
25
+ *
26
+ * The lifecycle of an item handed to the out-of-process Fronts publish worker
27
+ * (foundryd): it acknowledges the intent (`queued`), begins pushing to the
28
+ * external surfaces (`publishing`), confirms every surface is live (`live`),
29
+ * misses its target window (`late`), or gives up (`failed`). Only `live` (with a
30
+ * receipt) flips the item to `published`. NULL means the item was never routed
31
+ * through the additive Fronts publish path (the untouched D1 cron path owns it).
32
+ */
33
+ export type FrontsPublishStatus = 'queued' | 'publishing' | 'live' | 'late' | 'failed'
34
+
23
35
  /** video_content.processing_state CHECK domain (bead 08 state machine). */
24
36
  export type VideoProcessingState =
25
37
  | 'pending'
@@ -76,6 +88,55 @@ export interface ContentItemRow {
76
88
  metadata_json: string
77
89
  /** Optional namespaced source key used for idempotent archive imports (migration 0020). */
78
90
  source_id: string | null
91
+ /**
92
+ * Lifecycle marker for the additive out-of-process Fronts publish path
93
+ * (migration 0026). NULL for items the legacy D1 cron path owns.
94
+ */
95
+ fronts_publish_status: FrontsPublishStatus | null
96
+ /**
97
+ * Monotonic integer revision of this item's canonical content (migration
98
+ * 0027). 1 on create; advanced by `updateContentItem` only. This is the
99
+ * `version` component of `intent_id = masthead:<content_id>:<version>`, so it
100
+ * is deliberately NOT advanced by `fronts_publish_status` marker writes or by
101
+ * a schedule poll.
102
+ */
103
+ canonical_version: number
104
+ /**
105
+ * JSON array of Fronts publish target slugs (migration 0027), or NULL when
106
+ * the item has not been armed for the out-of-process publish path. NULL is a
107
+ * loud hydration error, never an empty target list. Distinct from `channel`,
108
+ * which is the single editorial Fronts channel slug.
109
+ */
110
+ fronts_channel_targets: string | null
111
+ /**
112
+ * Raw CMS-owned per-item publish trigger token (migration 0027), secret at
113
+ * rest like `video_content.processing_trigger_token`. Never leaves the
114
+ * package: the publish-intent contract exposes only `sha256:<hex>` of it.
115
+ */
116
+ fronts_publish_trigger_token: string | null
117
+ }
118
+
119
+ /**
120
+ * Durable receiver log for foundryd's outbound publish callbacks (migration
121
+ * 0026). One row per `event_id` — the idempotency key. `receipt_json` stores the
122
+ * platform receipt (published_urls / platform_post_ids / sha256); `attempt_id`
123
+ * links to the `content_publication_attempts` 2PC ledger row when a confirmed
124
+ * `live` callback drove the publish.
125
+ */
126
+ export interface FrontsPublishEventRow {
127
+ event_id: string
128
+ intent_id: string | null
129
+ content_id: string
130
+ version: number | null
131
+ correlation_id: string | null
132
+ status: FrontsPublishStatus
133
+ occurred_at: number | null
134
+ receipt_json: string | null
135
+ error: string | null
136
+ /** 1 when this callback flipped (or confirmed) the item to published. */
137
+ published: number
138
+ attempt_id: string | null
139
+ received_at: number
79
140
  }
80
141
 
81
142
  /** Standalone topic taxonomy row. Counts are derived from content_items.primary_topic. */
@@ -9,3 +9,12 @@ export const SURVEY_RESPONSE_ROLES: ReadonlySet<CmsRole> = new Set([
9
9
  export function canViewSurveyResponses(role: CmsRole | null | undefined): boolean {
10
10
  return role != null && SURVEY_RESPONSE_ROLES.has(role)
11
11
  }
12
+
13
+ // Aggregates are for anyone who can open the screen; individual answers and the
14
+ // respondent attached to them are not. Splitting these lets a team see what
15
+ // readers said in aggregate without exposing every member's words by name.
16
+ export const SURVEY_VERBATIM_ROLES: ReadonlySet<CmsRole> = new Set(['owner', 'senior_editor'])
17
+
18
+ export function canViewSurveyVerbatims(role: CmsRole | null | undefined): boolean {
19
+ return role != null && SURVEY_VERBATIM_ROLES.has(role)
20
+ }
@@ -29,6 +29,18 @@ export const SURVEY_ERROR_DEFINITIONS = {
29
29
  status: 502,
30
30
  error: 'Survey reporting could not be loaded.',
31
31
  },
32
+ // Distinct causes, distinct codes. These previously collapsed into
33
+ // survey_upstream_failure, so the screen showed one message for an auth
34
+ // rejection, an upstream outage, and a payload it could not parse — which is
35
+ // how a broken fetch receiver in a consuming host stayed invisible for a month.
36
+ survey_upstream_auth_failed: {
37
+ status: 502,
38
+ error: 'Survey reporting rejected this site\u2019s credentials.',
39
+ },
40
+ survey_invalid_response: {
41
+ status: 502,
42
+ error: 'Survey reporting returned a response we could not read.',
43
+ },
32
44
  survey_internal_failure: {
33
45
  status: 500,
34
46
  error: 'Survey reporting could not be loaded.',
@@ -29,6 +29,11 @@ const BoundedRecordIdSchema = z
29
29
  .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
30
30
  const IsoTimestampSchema = z.string().datetime({ offset: true })
31
31
  const CountSchema = z.number().int().nonnegative().max(MAX_COUNT)
32
+ const AnswerValueSchema = z.union([
33
+ z.string().max(MAX_FREE_TEXT_LENGTH),
34
+ z.number().int(),
35
+ z.array(z.string().max(MAX_OPTION_LENGTH)).max(MAX_CHOICE_OPTIONS),
36
+ ])
32
37
  const NonemptyBoundedStringSchema = z
33
38
  .string()
34
39
  .min(1)
@@ -208,6 +213,35 @@ export const SurveyResultsSchema = z
208
213
  submittedAt: IsoTimestampSchema,
209
214
  respondentSegment: SurveyRespondentSegmentSchema,
210
215
  anonymous: z.boolean(),
216
+ // Opaque upstream contact handle. The host resolves its own member
217
+ // record from this; no email or name crosses the reporting boundary.
218
+ // Optional because the reporting upstream only emits these when the
219
+ // caller opts in. A consumer that always required them could not
220
+ // read a legacy payload, and this object is strict, so a consumer
221
+ // that never expected them discards the whole response.
222
+ contactId: BoundedRecordIdSchema.nullable().optional(),
223
+ // Host-enriched, optional. The consuming site joins contactId to its
224
+ // own member record and attaches a display label here; the reporting
225
+ // upstream never sees it. Absent means "not resolved", not "anonymous".
226
+ respondent: z
227
+ .object({
228
+ label: NonemptyBoundedStringSchema,
229
+ detail: z.string().max(MAX_OPTION_LENGTH).nullable().optional(),
230
+ })
231
+ .strict()
232
+ .nullable()
233
+ .optional(),
234
+ answers: z
235
+ .array(
236
+ z
237
+ .object({
238
+ questionId: SurveyQuestionIdSchema,
239
+ value: AnswerValueSchema,
240
+ })
241
+ .strict(),
242
+ )
243
+ .max(MAX_QUESTIONS)
244
+ .optional(),
211
245
  })
212
246
  .strict(),
213
247
  )
@@ -256,6 +290,19 @@ export const SurveyResultsSchema = z
256
290
  if (results.responses.length > results.survey.responseCount) {
257
291
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Response rows exceed survey total' })
258
292
  }
293
+ for (const [index, response] of results.responses.entries()) {
294
+ const seen = new Set<string>()
295
+ for (const [answerIndex, answer] of (response.answers ?? []).entries()) {
296
+ if (seen.has(answer.questionId)) {
297
+ ctx.addIssue({
298
+ code: z.ZodIssueCode.custom,
299
+ path: ['responses', index, 'answers', answerIndex, 'questionId'],
300
+ message: 'Answers must reference distinct questions',
301
+ })
302
+ }
303
+ seen.add(answer.questionId)
304
+ }
305
+ }
259
306
  const responses = new Map<string, (typeof results.responses)[number]>()
260
307
  for (const [index, response] of results.responses.entries()) {
261
308
  if (responses.has(response.id)) {
@@ -0,0 +1,18 @@
1
+ // src/ui/api/fronts/publish-callback.ts
2
+ // POST /admin/api/fronts/publish-callback — receiver for foundryd's outbound
3
+ // publish-lifecycle callbacks. Idempotent by event_id / Idempotency-Key. On a
4
+ // confirmed `live` (with a receipt) it drives the item to published through the
5
+ // content_publication_attempts 2PC ledger. HMAC-SHA256 (X-Foundry-Signature vs
6
+ // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher.
7
+ import type { APIContext } from 'astro'
8
+ import type { RouteContext } from '../../../routes/context.js'
9
+ import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
10
+ import { buildSiteAuthz } from '../_authz.js'
11
+
12
+ function toCtx(c: APIContext): RouteContext {
13
+ const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
+ return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
+ }
16
+
17
+ export const POST = (c: APIContext) =>
18
+ createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).publishCallback(toCtx(c))
@@ -0,0 +1,18 @@
1
+ // src/ui/api/fronts/schedule.ts
2
+ // GET /admin/api/fronts/schedule?window=<from>..<to> — the machine-to-machine
3
+ // poll surface for the out-of-process Fronts publish worker (foundryd). Returns
4
+ // `scheduled` items due for publishing. HMAC-SHA256 (X-Foundry-Signature vs
5
+ // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher. Sibling of the
6
+ // content foundry-callback / insights-ingest M2M routes.
7
+ import type { APIContext } from 'astro'
8
+ import type { RouteContext } from '../../../routes/context.js'
9
+ import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
10
+ import { buildSiteAuthz } from '../_authz.js'
11
+
12
+ function toCtx(c: APIContext): RouteContext {
13
+ const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
+ return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
+ }
16
+
17
+ export const GET = (c: APIContext) =>
18
+ createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).schedule(toCtx(c))
@@ -27,6 +27,7 @@ import { Icon } from '../icons.js'
27
27
  import { uploadMediaAsset } from '../screens/media-upload.js'
28
28
  import { AiAssistPanel } from './AiAssistPanel.js'
29
29
  import { AUTOSAVE_DEBOUNCE_MS, shouldSnapshot } from './autosave.js'
30
+ import { type BodyWarning, describeBodyWarnings, sortBodyWarnings } from './body-warnings.js'
30
31
  import {
31
32
  buildContentCreatePayload,
32
33
  buildContentUpdatePayload,
@@ -185,6 +186,12 @@ interface FormState {
185
186
  lastSavedAt: number | null
186
187
  /** Whether the AI assist panel is open. */
187
188
  aiOpen: boolean
189
+ /**
190
+ * Warn-severity body lint from the last save (#329). Previously the 200
191
+ * payload was discarded, which is how a malformed Sources section reached a
192
+ * published page — the server reported it and nothing showed the editor.
193
+ */
194
+ bodyWarnings: BodyWarning[]
188
195
  }
189
196
 
190
197
  const EMPTY_CONTENT_METADATA_SELECTS: ContentMetadataSelectField[] = []
@@ -229,6 +236,7 @@ function createEmptyFormState(docId: string | null, metadata: ContentMetadata =
229
236
  savedId: docId,
230
237
  saving: false,
231
238
  saveError: null,
239
+ bodyWarnings: [],
232
240
  lastSavedAt: null,
233
241
  aiOpen: false,
234
242
  }
@@ -441,6 +449,17 @@ export function ContentForm({
441
449
  return
442
450
  }
443
451
 
452
+ // Carry warn-severity body lint into the editor. The server has
453
+ // reported these since #453; dropping the 200 payload here is what
454
+ // left them invisible.
455
+ const saved = (await res.json().catch(() => ({}))) as {
456
+ bodyWarnings?: BodyWarning[]
457
+ }
458
+ setForm((prev) => ({
459
+ ...prev,
460
+ bodyWarnings: Array.isArray(saved.bodyWarnings) ? saved.bodyWarnings : [],
461
+ }))
462
+
444
463
  // Snapshot cadence check
445
464
  const now = Date.now()
446
465
  savesSinceSnapshot.current += 1
@@ -1104,6 +1123,33 @@ export function ContentForm({
1104
1123
  )}
1105
1124
  </div>
1106
1125
 
1126
+ {/* Body lint (#329). Shown beside the body editor so the correction is
1127
+ next to the text it applies to, rather than in a toast that is gone
1128
+ before the editor can act on it. */}
1129
+ {form.bodyWarnings.length > 0 && (
1130
+ <div style={bodyWarningPanel} role="status" aria-label="Body formatting warnings">
1131
+ <div style={bodyWarningTitle}>
1132
+ <Icon name="edit" size={12} style={{ color: 'var(--ink-faint)' }} />
1133
+ <span>
1134
+ {form.bodyWarnings.length}{' '}
1135
+ {form.bodyWarnings.length === 1 ? 'formatting issue' : 'formatting issues'} — saved,
1136
+ but these will block publishing
1137
+ </span>
1138
+ </div>
1139
+ <ul style={bodyWarningList}>
1140
+ {sortBodyWarnings(describeBodyWarnings(form.body, form.bodyWarnings)).map((warning) => (
1141
+ <li key={`${warning.code}:${warning.line ?? 'x'}`} style={bodyWarningItem}>
1142
+ <span style={bodyWarningLine}>
1143
+ {warning.line === null ? 'Sources' : `Line ${warning.line}`}
1144
+ </span>
1145
+ <span>{warning.message}</span>
1146
+ {warning.excerpt ? <code style={bodyWarningCode}>{warning.excerpt}</code> : null}
1147
+ </li>
1148
+ ))}
1149
+ </ul>
1150
+ </div>
1151
+ )}
1152
+
1107
1153
  {/* Footer: word count + read time (only for prose content) */}
1108
1154
  {(contentType === 'article' || contentType === 'newsletter' || contentType === 'page') && (
1109
1155
  <div style={footer}>
@@ -1680,6 +1726,53 @@ const dekInput: React.CSSProperties = {
1680
1726
  resize: 'none',
1681
1727
  }
1682
1728
 
1729
+ const bodyWarningPanel: React.CSSProperties = {
1730
+ marginTop: 16,
1731
+ padding: '12px 14px',
1732
+ borderRadius: 6,
1733
+ border: '1px solid var(--border-soft)',
1734
+ borderLeft: '3px solid var(--warn, #b8860b)',
1735
+ background: 'var(--surface-sunken, rgba(0,0,0,0.02))',
1736
+ }
1737
+
1738
+ const bodyWarningTitle: React.CSSProperties = {
1739
+ display: 'flex',
1740
+ alignItems: 'center',
1741
+ gap: 8,
1742
+ fontFamily: 'var(--mono)',
1743
+ fontSize: 11.5,
1744
+ color: 'var(--ink-muted)',
1745
+ marginBottom: 8,
1746
+ }
1747
+
1748
+ const bodyWarningList: React.CSSProperties = {
1749
+ display: 'grid',
1750
+ gap: 8,
1751
+ margin: 0,
1752
+ padding: 0,
1753
+ listStyle: 'none',
1754
+ }
1755
+
1756
+ const bodyWarningItem: React.CSSProperties = {
1757
+ display: 'grid',
1758
+ gap: 3,
1759
+ fontSize: 13,
1760
+ lineHeight: 1.45,
1761
+ }
1762
+
1763
+ const bodyWarningLine: React.CSSProperties = {
1764
+ fontFamily: 'var(--mono)',
1765
+ fontSize: 11,
1766
+ color: 'var(--ink-faint)',
1767
+ }
1768
+
1769
+ const bodyWarningCode: React.CSSProperties = {
1770
+ fontFamily: 'var(--mono)',
1771
+ fontSize: 11.5,
1772
+ color: 'var(--ink-muted)',
1773
+ overflowWrap: 'anywhere',
1774
+ }
1775
+
1683
1776
  const footer: React.CSSProperties = {
1684
1777
  display: 'flex',
1685
1778
  alignItems: 'center',
@@ -0,0 +1,78 @@
1
+ // Turn validator output into something an editor can act on (#329).
2
+ //
3
+ // The API returns `bodyWarnings` with character spans. An editor cannot use a
4
+ // character offset — the design asks for "the affected line and a plain-language
5
+ // correction" — so this maps each span onto a 1-based line number and the text
6
+ // of that line.
7
+ //
8
+ // Pure on purpose: the mapping is the part worth testing, and keeping it out of
9
+ // the component means it can be tested without rendering anything.
10
+
11
+ export interface BodyWarning {
12
+ code: string
13
+ message: string
14
+ span?: { start: number; end: number }
15
+ }
16
+
17
+ export interface DescribedBodyWarning {
18
+ code: string
19
+ message: string
20
+ /** 1-based line number, or null when the warning carries no span. */
21
+ line: number | null
22
+ /** The offending line, trimmed. Null when there is no span to locate. */
23
+ excerpt: string | null
24
+ }
25
+
26
+ /** Longest excerpt shown inline before truncation. */
27
+ const MAX_EXCERPT = 120
28
+
29
+ function lineAt(markdown: string, offset: number): { line: number; text: string } | null {
30
+ if (offset < 0 || offset > markdown.length) return null
31
+ const before = markdown.slice(0, offset)
32
+ const line = before.split('\n').length
33
+ const lineStart = before.lastIndexOf('\n') + 1
34
+ const nextBreak = markdown.indexOf('\n', lineStart)
35
+ const text = markdown.slice(lineStart, nextBreak === -1 ? markdown.length : nextBreak)
36
+ return { line, text }
37
+ }
38
+
39
+ function truncate(value: string): string {
40
+ return value.length > MAX_EXCERPT ? `${value.slice(0, MAX_EXCERPT - 1)}…` : value
41
+ }
42
+
43
+ /**
44
+ * Attach a line number and excerpt to each warning.
45
+ *
46
+ * Warnings without a span keep `line: null` rather than being dropped or given
47
+ * a fabricated line 1 — a wrong line number sends an editor to the wrong place,
48
+ * which is worse than admitting the position is unknown.
49
+ */
50
+ export function describeBodyWarnings(
51
+ markdown: string | null | undefined,
52
+ warnings: readonly BodyWarning[] | null | undefined,
53
+ ): DescribedBodyWarning[] {
54
+ if (!warnings || warnings.length === 0) return []
55
+ const source = markdown ?? ''
56
+
57
+ return warnings.map((warning) => {
58
+ const located = warning.span ? lineAt(source, warning.span.start) : null
59
+ return {
60
+ code: warning.code,
61
+ message: warning.message,
62
+ line: located ? located.line : null,
63
+ excerpt: located ? truncate(located.text.trim()) : null,
64
+ }
65
+ })
66
+ }
67
+
68
+ /** Stable ordering for display: by line, then by code. Unlocated ones last. */
69
+ export function sortBodyWarnings(
70
+ warnings: readonly DescribedBodyWarning[],
71
+ ): DescribedBodyWarning[] {
72
+ return [...warnings].sort((a, b) => {
73
+ if (a.line === b.line) return a.code.localeCompare(b.code)
74
+ if (a.line === null) return 1
75
+ if (b.line === null) return -1
76
+ return a.line - b.line
77
+ })
78
+ }