@growth-labs/cms 0.5.11 → 0.5.13

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 (56) hide show
  1. package/README.md +22 -0
  2. package/dist/engine/index.d.ts +3 -2
  3. package/dist/engine/index.d.ts.map +1 -1
  4. package/dist/engine/index.js +3 -2
  5. package/dist/engine/index.js.map +1 -1
  6. package/dist/engine/publication.d.ts.map +1 -1
  7. package/dist/engine/publication.js +23 -5
  8. package/dist/engine/publication.js.map +1 -1
  9. package/dist/engine/published-content.d.ts.map +1 -1
  10. package/dist/engine/published-content.js +14 -5
  11. package/dist/engine/published-content.js.map +1 -1
  12. package/dist/engine/publisher.d.ts +3 -1
  13. package/dist/engine/publisher.d.ts.map +1 -1
  14. package/dist/engine/publisher.js +39 -31
  15. package/dist/engine/publisher.js.map +1 -1
  16. package/dist/engine/revisions.d.ts +3 -0
  17. package/dist/engine/revisions.d.ts.map +1 -1
  18. package/dist/engine/revisions.js +7 -1
  19. package/dist/engine/revisions.js.map +1 -1
  20. package/dist/engine/tags.d.ts +9 -2
  21. package/dist/engine/tags.d.ts.map +1 -1
  22. package/dist/engine/tags.js +32 -21
  23. package/dist/engine/tags.js.map +1 -1
  24. package/dist/engine/taxonomy.d.ts +31 -0
  25. package/dist/engine/taxonomy.d.ts.map +1 -0
  26. package/dist/engine/taxonomy.js +125 -0
  27. package/dist/engine/taxonomy.js.map +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/routes/content.d.ts.map +1 -1
  32. package/dist/routes/content.js +98 -27
  33. package/dist/routes/content.js.map +1 -1
  34. package/dist/routes/tags.d.ts.map +1 -1
  35. package/dist/routes/tags.js +36 -3
  36. package/dist/routes/tags.js.map +1 -1
  37. package/dist/schema/migrations.d.ts.map +1 -1
  38. package/dist/schema/migrations.js +15 -1
  39. package/dist/schema/migrations.js.map +1 -1
  40. package/dist/schema/types.d.ts +1 -0
  41. package/dist/schema/types.d.ts.map +1 -1
  42. package/dist/schema/types.js.map +1 -1
  43. package/migrations/0023_content_tag_link_labels.sql +11 -0
  44. package/package.json +1 -1
  45. package/src/engine/index.ts +14 -0
  46. package/src/engine/publication.ts +27 -12
  47. package/src/engine/published-content.ts +14 -8
  48. package/src/engine/publisher.ts +76 -39
  49. package/src/engine/revisions.ts +10 -1
  50. package/src/engine/tags.ts +39 -19
  51. package/src/engine/taxonomy.ts +226 -0
  52. package/src/index.ts +2 -0
  53. package/src/routes/content.ts +91 -30
  54. package/src/routes/tags.ts +36 -3
  55. package/src/schema/migrations.ts +15 -1
  56. package/src/schema/types.ts +1 -0
@@ -36,6 +36,7 @@ import {
36
36
  evaluateContentBodyForPublish,
37
37
  getContentItem,
38
38
  getContentRelations,
39
+ getContentTagPairs,
39
40
  publishContent,
40
41
  scheduleContent,
41
42
  unpublishContent,
@@ -50,6 +51,7 @@ import {
50
51
  } from '../engine/revisions.js'
51
52
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
52
53
  import { softDeleteContent } from '../engine/soft-delete.js'
54
+ import { TagInputError } from '../engine/taxonomy.js'
53
55
  import type { ContentStatus } from '../schema/types.js'
54
56
  import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
55
57
  import type { CmsRouteConfig } from './config.js'
@@ -582,13 +584,7 @@ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
582
584
  }
583
585
 
584
586
  async function getContentTags(ctx: RouteContext, id: string): Promise<string[]> {
585
- const result = await ctx.db
586
- .prepare(
587
- 'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ? ORDER BY t.label ASC',
588
- )
589
- .bind(id)
590
- .all<{ slug: string }>()
591
- return (result.results || []).map((row) => row.slug)
587
+ return (await getContentTagPairs(ctx.db, id)).map((row) => row.slug)
592
588
  }
593
589
 
594
590
  async function getMediaAssetUrls(db: D1Database, ids: string[]): Promise<Record<string, string>> {
@@ -612,6 +608,19 @@ async function readJson(ctx: RouteContext): Promise<unknown> {
612
608
  return ctx.request.json().catch(() => null)
613
609
  }
614
610
 
611
+ function tagInputErrorResponse(error: TagInputError): Response {
612
+ return json(
613
+ {
614
+ error: 'Invalid tags',
615
+ code: error.code,
616
+ limit: error.limit,
617
+ actual: error.actual,
618
+ index: error.index,
619
+ },
620
+ 400,
621
+ )
622
+ }
623
+
615
624
  export interface ContentRouteHandlers {
616
625
  /** GET /content — cursor-paginated list (q, author, status, types filters; excludes deleted). */
617
626
  list(ctx: RouteContext): Promise<Response>
@@ -1034,6 +1043,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1034
1043
  try {
1035
1044
  created = await createContent(ctx.db, parsed.data)
1036
1045
  } catch (error) {
1046
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1037
1047
  const conflict = verifiedMediaDurationConflictResponse(error)
1038
1048
  if (conflict) return conflict
1039
1049
  throw error
@@ -1073,17 +1083,30 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1073
1083
  const item = await getContentItem(ctx.db, id)
1074
1084
  if (!item) return json({ error: 'Not found' }, 404)
1075
1085
 
1076
- const [content, tags, relations, author] = await Promise.all([
1077
- getContentPayload(ctx, item.type, id),
1078
- getContentTags(ctx, id),
1079
- getContentRelations(ctx.db, id),
1080
- item.author_id
1081
- ? ctx.db
1082
- .prepare('SELECT name, slug FROM authors WHERE id = ? LIMIT 1')
1083
- .bind(item.author_id)
1084
- .first<{ name: string | null; slug: string | null }>()
1085
- : Promise.resolve(null),
1086
- ])
1086
+ let content: unknown
1087
+ let tags: string[]
1088
+ let relations: Awaited<ReturnType<typeof getContentRelations>>
1089
+ let author: { name: string | null; slug: string | null } | null
1090
+ try {
1091
+ const resolvedContent = await Promise.all([
1092
+ getContentPayload(ctx, item.type, id),
1093
+ getContentTags(ctx, id),
1094
+ getContentRelations(ctx.db, id),
1095
+ item.author_id
1096
+ ? ctx.db
1097
+ .prepare('SELECT name, slug FROM authors WHERE id = ? LIMIT 1')
1098
+ .bind(item.author_id)
1099
+ .first<{ name: string | null; slug: string | null }>()
1100
+ : Promise.resolve(null),
1101
+ ])
1102
+ content = resolvedContent[0]
1103
+ tags = resolvedContent[1]
1104
+ relations = resolvedContent[2]
1105
+ author = resolvedContent[3]
1106
+ } catch (error) {
1107
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1108
+ throw error
1109
+ }
1087
1110
  const contentRecord = content as Record<string, unknown> | null
1088
1111
  const thumbnailImageId =
1089
1112
  typeof contentRecord?.thumbnail_image_id === 'string'
@@ -1193,6 +1216,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1193
1216
  try {
1194
1217
  updated = await updateContentItem(ctx.db, id, parsed.data)
1195
1218
  } catch (error) {
1219
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1196
1220
  const conflict = verifiedMediaDurationConflictResponse(error)
1197
1221
  if (conflict) return conflict
1198
1222
  throw error
@@ -1332,7 +1356,6 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1332
1356
  422,
1333
1357
  )
1334
1358
  }
1335
-
1336
1359
  if (!existing.excerpt?.trim()) {
1337
1360
  let failureReason: string | null = null
1338
1361
  if (!resolved.hooks.llm) {
@@ -1370,8 +1393,19 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1370
1393
  }
1371
1394
  }
1372
1395
  }
1373
- await publishContent(ctx.db, id, now)
1374
- await createRevision(ctx.db, id, ctx.userId || null)
1396
+ try {
1397
+ await getContentTagPairs(ctx.db, id)
1398
+ } catch (error) {
1399
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1400
+ throw error
1401
+ }
1402
+ try {
1403
+ await publishContent(ctx.db, id, now)
1404
+ await createRevision(ctx.db, id, ctx.userId || null)
1405
+ } catch (error) {
1406
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1407
+ throw error
1408
+ }
1375
1409
 
1376
1410
  // Write first-party activity log row (cms_activity_log, P5 Task 3).
1377
1411
  // Wrapped so a logging failure never breaks the publish response.
@@ -1554,10 +1588,16 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1554
1588
  }
1555
1589
 
1556
1590
  if (action === 'snapshot') {
1557
- const revision = await createRevisionWithDelta(ctx.db, id, {
1558
- createdBy: ctx.userId ?? null,
1559
- tag: 'autosave',
1560
- })
1591
+ let revision: Awaited<ReturnType<typeof createRevisionWithDelta>>
1592
+ try {
1593
+ revision = await createRevisionWithDelta(ctx.db, id, {
1594
+ createdBy: ctx.userId ?? null,
1595
+ tag: 'autosave',
1596
+ })
1597
+ } catch (error) {
1598
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1599
+ throw error
1600
+ }
1561
1601
  if (!revision) return json({ error: 'Not found' }, 404)
1562
1602
  return json({ status: 'snapshot', revisionId: revision.id })
1563
1603
  }
@@ -1637,10 +1677,25 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1637
1677
 
1638
1678
  async foundryCallback(ctx) {
1639
1679
  // HMAC-guarded machine callback — NOT behind requirePublisher.
1640
- // The HMAC secret is read from ctx.env (Secrets Store binding on the
1641
- // Workers side; `env.FOUNDRY_HMAC_SECRET`). Never a value in code/D1.
1642
- const hmacSecret =
1643
- typeof ctx.env?.FOUNDRY_HMAC_SECRET === 'string' ? ctx.env.FOUNDRY_HMAC_SECRET : null
1680
+ // The HMAC secret arrives from ctx.env as either a plain per-Worker
1681
+ // secret string or a Secrets Store binding object (`{ get() }`) —
1682
+ // consumers bind it both ways. Never a value in code/D1. The old
1683
+ // string-only check silently nulled the binding shape and 500ed
1684
+ // every callback on Store-bound consumers.
1685
+ const rawHmac = ctx.env?.FOUNDRY_HMAC_SECRET as
1686
+ | string
1687
+ | { get(): Promise<string> }
1688
+ | undefined
1689
+ let hmacSecret: string | null = null
1690
+ if (typeof rawHmac === 'string') {
1691
+ hmacSecret = rawHmac
1692
+ } else if (rawHmac && typeof rawHmac.get === 'function') {
1693
+ try {
1694
+ hmacSecret = (await rawHmac.get()) || null
1695
+ } catch {
1696
+ hmacSecret = null
1697
+ }
1698
+ }
1644
1699
 
1645
1700
  if (!hmacSecret) {
1646
1701
  // No secret configured — refuse with 500 (misconfiguration).
@@ -1885,7 +1940,13 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1885
1940
  const rev = ctx.params.rev
1886
1941
  if (!rev) return json({ error: 'Missing rev' }, 400)
1887
1942
 
1888
- const ok = await restoreRevision(ctx.db, id, rev, { createdBy: ctx.userId ?? null })
1943
+ let ok: Awaited<ReturnType<typeof restoreRevision>>
1944
+ try {
1945
+ ok = await restoreRevision(ctx.db, id, rev, { createdBy: ctx.userId ?? null })
1946
+ } catch (error) {
1947
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
1948
+ throw error
1949
+ }
1889
1950
  if (!ok) return json({ error: 'Not found' }, 404)
1890
1951
 
1891
1952
  return json({ restored: true })
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from 'zod'
6
6
  import { createTag, deleteTag, getTag, listTags, renameTag, searchTags } from '../engine/tags.js'
7
+ import { TagInputError } from '../engine/taxonomy.js'
7
8
  import type { CmsRouteConfig } from './config.js'
8
9
  import { resolveConfig } from './config.js'
9
10
  import { json, type RouteContext } from './context.js'
@@ -12,6 +13,25 @@ const TagWriteSchema = z.object({
12
13
  label: z.string().min(1),
13
14
  })
14
15
 
16
+ function parseLimit(value: string | null): number | undefined {
17
+ if (value === null) return undefined
18
+ const limit = Number(value)
19
+ return Number.isSafeInteger(limit) ? limit : undefined
20
+ }
21
+
22
+ function tagInputErrorResponse(error: TagInputError): Response {
23
+ return json(
24
+ {
25
+ error: 'Invalid tag',
26
+ code: error.code,
27
+ limit: error.limit,
28
+ actual: error.actual,
29
+ index: error.index,
30
+ },
31
+ 400,
32
+ )
33
+ }
34
+
15
35
  export interface TagRouteHandlers {
16
36
  /**
17
37
  * GET /tags or GET /tags?q=<query>
@@ -42,8 +62,9 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
42
62
 
43
63
  const url = new URL(ctx.request.url)
44
64
  const q = url.searchParams.get('q')
65
+ const limit = parseLimit(url.searchParams.get('limit'))
45
66
 
46
- const tags = q ? await searchTags(ctx.db, q) : await listTags(ctx.db)
67
+ const tags = q ? await searchTags(ctx.db, q, { limit }) : await listTags(ctx.db, { limit })
47
68
  return json({ tags })
48
69
  },
49
70
 
@@ -55,7 +76,13 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
55
76
  if (!parsed.success) {
56
77
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
57
78
  }
58
- const tag = await createTag(ctx.db, parsed.data.label)
79
+ let tag: Awaited<ReturnType<typeof createTag>>
80
+ try {
81
+ tag = await createTag(ctx.db, parsed.data.label)
82
+ } catch (error) {
83
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
84
+ throw error
85
+ }
59
86
  if (!tag) return json({ error: 'Tag already exists or label is invalid' }, 409)
60
87
  return json({ tag }, 201)
61
88
  },
@@ -73,7 +100,13 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
73
100
  if (!parsed.success) {
74
101
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
75
102
  }
76
- const ok = await renameTag(ctx.db, id, parsed.data.label)
103
+ let ok: Awaited<ReturnType<typeof renameTag>>
104
+ try {
105
+ ok = await renameTag(ctx.db, id, parsed.data.label)
106
+ } catch (error) {
107
+ if (error instanceof TagInputError) return tagInputErrorResponse(error)
108
+ throw error
109
+ }
77
110
  if (!ok) return json({ error: 'Tag slug already exists or label is invalid' }, 409)
78
111
  const tag = await getTag(ctx.db, id)
79
112
  return json({ tag })
@@ -811,7 +811,21 @@ ALTER TABLE content_insight_dismissals
811
811
  ADD COLUMN reason_code TEXT CHECK (reason_code IS NULL OR reason_code IN ('off_topic', 'already_covered', 'cant_win', 'wrong_format', 'not_now', 'other'));
812
812
  ALTER TABLE content_insight_dismissals
813
813
  ADD COLUMN note TEXT CHECK (note IS NULL OR length(note) <= 500);
814
- `,
814
+ `,
815
+ },
816
+ {
817
+ id: '0023_content_tag_link_labels',
818
+ sql: `
819
+ ALTER TABLE content_tag_links
820
+ ADD COLUMN label TEXT;
821
+ UPDATE content_tag_links
822
+ SET label = (
823
+ SELECT label
824
+ FROM content_tags
825
+ WHERE content_tags.id = content_tag_links.tag_id
826
+ )
827
+ WHERE label IS NULL;
828
+ `,
815
829
  },
816
830
  ]
817
831
 
@@ -167,6 +167,7 @@ export interface ContentTagRow {
167
167
  export interface ContentTagLinkRow {
168
168
  content_id: string
169
169
  tag_id: string
170
+ label: string | null
170
171
  }
171
172
 
172
173
  export interface ContentRelationRow {