@beechcms/api 0.5.0 → 0.6.0-preview.2

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 (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -0,0 +1,558 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /// <reference types="@cloudflare/workers-types" />
6
+ import { Hono } from 'hono'
7
+ import type { Branch, Seed } from '@beechcms/core'
8
+ import {
9
+ nextBranchId,
10
+ validateSeedDefinitions,
11
+ planCreateSeed,
12
+ planExtendSeed,
13
+ planFtsRebuild,
14
+ generateDropTable,
15
+ generateDropColumn,
16
+ generateRenameColumn,
17
+ generateRetypeColumn,
18
+ BRANCH_ALIAS_RE,
19
+ } from '@beechcms/core'
20
+ import { publicProblem, internalErrorDetail } from '../../public/problem-details'
21
+ import { deleteR2Objects } from '../../upload'
22
+ import { extractMediaKeysFromData } from '../../media-utils'
23
+ import type { Env, Variables } from '../../types'
24
+
25
+ const SLUG_RE = /^[a-z0-9_]+$/
26
+
27
+ function requireAdmin(context: any) {
28
+ const role = context.get('jwtPayload')?.role
29
+ if (role !== 'admin') {
30
+ return publicProblem(context, {
31
+ type: 'forbidden',
32
+ title: 'Forbidden',
33
+ status: 403,
34
+ detail: 'Seed management requires admin role.',
35
+ })
36
+ }
37
+ return null
38
+ }
39
+
40
+ function actorFromContext(context: any) {
41
+ const jwt = context.get('jwtPayload')
42
+ return {
43
+ id: jwt?.sub ?? 'unknown',
44
+ email: jwt?.email ?? 'unknown',
45
+ name: [jwt?.name, jwt?.surname].filter(Boolean).join(' ') || null,
46
+ }
47
+ }
48
+
49
+ async function parseJsonBody(context: any): Promise<unknown> {
50
+ try { return await context.req.json() } catch {
51
+ return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
52
+ }
53
+ }
54
+
55
+ async function getActiveSeed(context: any, slug: string) {
56
+ const repo = context.get('seedRepository')
57
+ const existing = await repo.get(slug)
58
+ if (!existing || existing.status === 'deleted') {
59
+ return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
60
+ }
61
+ return existing
62
+ }
63
+
64
+ async function validateAndApplySeedDef(context: any, slug: string, candidate: Seed, action: 'create' | 'update', logDetails: any) {
65
+ const repo = context.get('seedRepository')
66
+ const activeSeeds = await repo.listActive()
67
+ const candidateSet = [...activeSeeds.filter((s: any) => s.slug !== slug), candidate]
68
+ const issues = validateSeedDefinitions(candidateSet)
69
+ const fatalIssues = issues.filter(i => i.fatal && i.slug === slug)
70
+ if (fatalIssues.length > 0) {
71
+ return publicProblem(context, {
72
+ type: 'validation-failed',
73
+ title: 'Validation failed',
74
+ status: 422,
75
+ detail: fatalIssues.flatMap(i => i.messages).join('; '),
76
+ })
77
+ }
78
+
79
+ const schemaMutator = context.get('schemaMutator')
80
+ const tableName = `content_${slug}`
81
+ const existingCols = await schemaMutator.getColumns(tableName)
82
+
83
+ try {
84
+ const stmts = existingCols === null
85
+ ? planCreateSeed(candidate)
86
+ : planExtendSeed(candidate, existingCols).statements
87
+ await schemaMutator.execDdl(stmts)
88
+ } catch (err) {
89
+ return publicProblem(context, {
90
+ type: 'ddl-failed',
91
+ title: 'DDL execution failed',
92
+ status: 422,
93
+ detail: internalErrorDetail(context.env, err),
94
+ })
95
+ }
96
+
97
+ await repo.upsert(slug, candidate, 'runtime')
98
+ await repo.bumpRegistryVersion()
99
+
100
+ const actor = actorFromContext(context)
101
+ context.get('activityLogger').log({ action, entityType: 'seed', entityId: slug, details: logDetails, actor })
102
+
103
+ return null
104
+ }
105
+
106
+ async function applyDestructiveSeedDef(context: any, slug: string, updatedDef: Seed, stmts: string[], logDetails: any) {
107
+ const repo = context.get('seedRepository')
108
+ const schemaMutator = context.get('schemaMutator')
109
+
110
+ try {
111
+ await schemaMutator.execDestructive(stmts)
112
+ } catch (err) {
113
+ return publicProblem(context, { type: 'ddl-failed', title: 'DDL failed', status: 422, detail: internalErrorDetail(context.env, err) })
114
+ }
115
+
116
+ await repo.upsert(slug, updatedDef, 'runtime')
117
+ await repo.bumpRegistryVersion()
118
+
119
+ const actor = actorFromContext(context)
120
+ context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: logDetails, actor })
121
+
122
+ return null
123
+ }
124
+
125
+ /** Typed confirm guard. Returns a 400 response on mismatch, null on success. */
126
+ function requireConfirm(context: any, expected: string, body: unknown) {
127
+ const confirm = (body as Record<string, unknown>)?.confirm
128
+ if (typeof confirm !== 'string' || confirm !== expected) {
129
+ return publicProblem(context, {
130
+ type: 'confirmation-required',
131
+ title: 'Confirmation required',
132
+ status: 400,
133
+ detail: `Destructive operation requires body field \`confirm\` equal to "${expected}".`,
134
+ })
135
+ }
136
+ return null
137
+ }
138
+
139
+ function validateIncomingBranches(incomingBranches: Branch[], storedBranches: Branch[], slug: string, context: any) {
140
+ const storedById = new Map(storedBranches.map((b: Branch) => [b.id, b]))
141
+
142
+ for (const branch of incomingBranches) {
143
+ const stored = branch.id ? storedById.get(branch.id) : undefined
144
+ if (stored) {
145
+ if (branch.alias !== stored.alias) {
146
+ return publicProblem(context, {
147
+ type: 'alias-rename-not-supported',
148
+ title: 'Alias rename not supported',
149
+ status: 422,
150
+ detail: `Branch '${branch.id}' alias rename from '${stored.alias}' to '${branch.alias}' is irreversible. Use PATCH /api/seeds/${slug}/branches/${branch.id}/rename with a typed confirmation.`,
151
+ })
152
+ }
153
+ if (branch.type !== stored.type) {
154
+ return publicProblem(context, {
155
+ type: 'branch-type-change-not-supported',
156
+ title: 'Branch type change not supported',
157
+ status: 422,
158
+ detail: `Branch '${branch.id}' type change from '${stored.type}' to '${branch.type}' is irreversible. Use PATCH /api/seeds/${slug}/branches/${branch.id}/retype with a typed confirmation.`,
159
+ })
160
+ }
161
+ } else if (!branch.id) {
162
+ const accSeed = { branches: incomingBranches.filter(b => b.id) }
163
+ branch.id = nextBranchId(accSeed)
164
+ }
165
+ }
166
+ return null
167
+ }
168
+
169
+ async function deleteSeedMediaObjects(context: any, slug: string, seed: Seed, schemaMutator: any) {
170
+ const fileBranches = seed.branches.filter((b: Branch) => b.type === 'file')
171
+ if (fileBranches.length === 0) return
172
+
173
+ try {
174
+ const dbCols = await schemaMutator.getColumns(`content_${slug}`)
175
+ if (!dbCols) return
176
+
177
+ const colList = fileBranches.map((b: Branch) => b.alias).filter((a: string) => dbCols.has(a)).join(', ')
178
+ if (!colList) return
179
+
180
+ const rs = await context.env.DB.prepare(`SELECT ${colList} FROM content_${slug}`).all()
181
+ const rows = (rs.results as Record<string, unknown>[]) ?? []
182
+ const r2Keys: string[] = []
183
+
184
+ for (const row of rows) {
185
+ for (const b of fileBranches) {
186
+ const val = row[b.alias]
187
+ if (!val) continue
188
+ const keys = extractMediaKeysFromData(seed, { [b.alias]: val })
189
+ r2Keys.push(...keys)
190
+ }
191
+ }
192
+
193
+ if (r2Keys.length > 0) {
194
+ await deleteR2Objects(context, r2Keys).catch(() => { /* non-fatal */ })
195
+ }
196
+ } catch { /* non-fatal: drop proceeds */ }
197
+ }
198
+
199
+ export const seedsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
200
+
201
+ seedsApp.use('*', async (context, next) => {
202
+ const denied = requireAdmin(context)
203
+ if (denied) return denied
204
+ await next()
205
+ })
206
+
207
+ /** GET /api/seeds — list all seed records (active + deleted). Admin-only. */
208
+ seedsApp.get('/', async (context) => {
209
+ const records = await context.get('seedRepository').listAll()
210
+ return context.json(records)
211
+ })
212
+
213
+ /** GET /api/seeds/:slug — single record by slug. Admin-only. */
214
+ seedsApp.get('/:slug', async (context) => {
215
+ const slug = context.req.param('slug')
216
+ const record = await context.get('seedRepository').get(slug)
217
+ if (!record) {
218
+ return publicProblem(context, {
219
+ type: 'seed-not-found',
220
+ title: 'Seed not found',
221
+ status: 404,
222
+ detail: `No seed with slug '${slug}'.`,
223
+ })
224
+ }
225
+ return context.json(record)
226
+ })
227
+
228
+ /** POST /api/seeds — create a new content type. */
229
+ seedsApp.post('/', async (context) => {
230
+ const body = await parseJsonBody(context)
231
+ if (body instanceof Response) return body
232
+
233
+ const candidate = body as Seed
234
+ const slug = candidate?.slug
235
+
236
+ if (!slug || !SLUG_RE.test(slug)) {
237
+ return publicProblem(context, { type: 'invalid-json', title: 'Bad Request', status: 400, detail: `slug must match ${SLUG_RE.source}.` })
238
+ }
239
+
240
+ const repo = context.get('seedRepository')
241
+ const existing = await repo.get(slug)
242
+
243
+ if (existing?.status === 'active') {
244
+ return publicProblem(context, {
245
+ type: 'slug-conflict',
246
+ title: 'Slug conflict',
247
+ status: 409,
248
+ detail: `An active seed with slug '${slug}' already exists.`,
249
+ })
250
+ }
251
+
252
+ // Assign ids to branches that are missing one
253
+ const accSeed: Pick<Seed, 'branches'> = { branches: [] }
254
+ const branches: Branch[] = Array.isArray(candidate.branches) ? candidate.branches : []
255
+ for (const branch of branches) {
256
+ if (!branch.id) {
257
+ branch.id = nextBranchId(accSeed)
258
+ }
259
+ accSeed.branches.push({ ...branch })
260
+ }
261
+
262
+ // Default displayNameAlias to first text branch if not provided
263
+ if (!candidate.displayNameAlias) {
264
+ const firstText = branches.find(b => b.type === 'text')
265
+ if (!firstText) {
266
+ return publicProblem(context, { type: 'invalid-json', title: 'Bad Request', status: 400, detail: 'displayNameAlias is required (or include at least one text branch so it can be inferred).' })
267
+ }
268
+ candidate.displayNameAlias = firstText.alias
269
+ }
270
+ candidate.branches = branches
271
+
272
+ const error = await validateAndApplySeedDef(context, slug, candidate, 'create', { slug })
273
+ if (error) return error
274
+
275
+ return context.json({ slug }, 201)
276
+ })
277
+
278
+ /** PUT /api/seeds/:slug — replace definition (additive-only). */
279
+ seedsApp.put('/:slug', async (context) => {
280
+ const slug = context.req.param('slug')
281
+ const body = await parseJsonBody(context)
282
+ if (body instanceof Response) return body
283
+
284
+ const existing = await getActiveSeed(context, slug)
285
+ if (existing instanceof Response) return existing
286
+
287
+ const incoming = body as Seed
288
+ const storedBranches = existing.definition.branches
289
+ const incomingBranches: Branch[] = Array.isArray(incoming.branches) ? incoming.branches : []
290
+
291
+ const validationError = validateIncomingBranches(incomingBranches, storedBranches, slug, context)
292
+ if (validationError) return validationError
293
+
294
+ const candidate: Seed = { ...incoming, slug, branches: incomingBranches }
295
+
296
+ const error = await validateAndApplySeedDef(context, slug, candidate, 'update', { slug })
297
+ if (error) return error
298
+
299
+ return context.json({ slug })
300
+ })
301
+
302
+ /** POST /api/seeds/:slug/branches — add a single branch. */
303
+ seedsApp.post('/:slug/branches', async (context) => {
304
+ const slug = context.req.param('slug')
305
+ const body = await parseJsonBody(context)
306
+ if (body instanceof Response) return body
307
+
308
+ const existing = await getActiveSeed(context, slug)
309
+ if (existing instanceof Response) return existing
310
+
311
+ const newBranch = body as Branch
312
+ newBranch.id = nextBranchId(existing.definition)
313
+
314
+ const candidate: Seed = {
315
+ ...existing.definition,
316
+ branches: [...existing.definition.branches, newBranch],
317
+ }
318
+
319
+ const error = await validateAndApplySeedDef(context, slug, candidate, 'update', { addedBranch: newBranch.id })
320
+ if (error) return error
321
+
322
+ return context.json({ id: newBranch.id })
323
+ })
324
+
325
+ /** DELETE /api/seeds/:slug — soft-delete a content type. */
326
+ seedsApp.delete('/:slug', async (context) => {
327
+ const slug = context.req.param('slug')
328
+ const repo = context.get('seedRepository')
329
+
330
+ const existing = await getActiveSeed(context, slug)
331
+ if (existing instanceof Response) return existing
332
+
333
+ const backrefMap = context.get('backrefMap')
334
+ const inbound = backrefMap.get(slug)
335
+ if (inbound && inbound.length > 0) {
336
+ const referencers = [...new Set(inbound.map((r: any) => r.sourceSlug))]
337
+ return publicProblem(context, {
338
+ type: 'seed-referenced',
339
+ title: 'Seed referenced',
340
+ status: 409,
341
+ detail: `Seed '${slug}' is referenced by: ${referencers.join(', ')}. Remove those relations first.`,
342
+ })
343
+ }
344
+
345
+ await repo.softDelete(slug)
346
+ await repo.bumpRegistryVersion()
347
+
348
+ const actor = actorFromContext(context)
349
+ context.get('activityLogger').log({ action: 'delete', entityType: 'seed', entityId: slug, details: { slug }, actor })
350
+
351
+ return context.json({ success: true })
352
+ })
353
+
354
+ /** GET /api/seeds/:slug/orphans — columns in DB but absent from the definition. */
355
+ seedsApp.get('/:slug/orphans', async (context) => {
356
+ const slug = context.req.param('slug')
357
+ const existing = await getActiveSeed(context, slug)
358
+ if (existing instanceof Response) return existing
359
+
360
+ const schemaMutator = context.get('schemaMutator')
361
+ const dbCols = await schemaMutator.getColumns(`content_${slug}`)
362
+ if (!dbCols) return context.json({ orphans: [] })
363
+
364
+ const knownAliases = new Set([
365
+ 'id', 'slug', 'status', 'created_at', 'updated_at',
366
+ ...existing.definition.branches
367
+ .filter((b: Branch) => !(b.type === 'relation' && b.multiple === true))
368
+ .map((b: Branch) => b.alias),
369
+ ])
370
+ const orphans = [...dbCols].filter((col: string) => !knownAliases.has(col))
371
+ return context.json({ orphans })
372
+ })
373
+
374
+ /** POST /api/seeds/:slug/fts/rebuild — rebuild FTS table + triggers. */
375
+ seedsApp.post('/:slug/fts/rebuild', async (context) => {
376
+ const slug = context.req.param('slug')
377
+ const existing = await getActiveSeed(context, slug)
378
+ if (existing instanceof Response) return existing
379
+
380
+ const stmts = planFtsRebuild(existing.definition)
381
+ if (stmts.length > 0) {
382
+ await context.get('schemaMutator').execDestructive(stmts)
383
+ }
384
+
385
+ const actor = actorFromContext(context)
386
+ context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: { op: 'fts-rebuild' }, actor })
387
+
388
+ return context.json({ success: true })
389
+ })
390
+
391
+ /** DELETE /api/seeds/:slug/hard — hard delete: drops tables + deletes row. */
392
+ seedsApp.delete('/:slug/hard', async (context) => {
393
+ const slug = context.req.param('slug')
394
+ const body = await parseJsonBody(context)
395
+ if (body instanceof Response) return body
396
+
397
+ const confirmErr = requireConfirm(context, slug, body)
398
+ if (confirmErr) return confirmErr
399
+
400
+ const existing = await getActiveSeed(context, slug)
401
+ if (existing instanceof Response) return existing
402
+
403
+ const backrefMap = context.get('backrefMap')
404
+ const inbound = backrefMap.get(slug)
405
+ if (inbound && inbound.length > 0) {
406
+ const referencers = [...new Set(inbound.map((r: any) => r.sourceSlug))]
407
+ return publicProblem(context, {
408
+ type: 'seed-referenced',
409
+ title: 'Seed referenced',
410
+ status: 409,
411
+ detail: `Seed '${slug}' is referenced by: ${referencers.join(', ')}. Remove those relations first.`,
412
+ })
413
+ }
414
+
415
+ const repo = context.get('seedRepository')
416
+ const schemaMutator = context.get('schemaMutator')
417
+ const seed = existing.definition
418
+
419
+ await deleteSeedMediaObjects(context, slug, seed, schemaMutator)
420
+
421
+ await schemaMutator.execDestructive(generateDropTable(seed))
422
+ await repo.hardDelete(slug)
423
+ await repo.bumpRegistryVersion()
424
+
425
+ const actor = actorFromContext(context)
426
+ context.get('activityLogger').log({ action: 'delete', entityType: 'seed', entityId: slug, details: { op: 'hard-delete', slug }, actor })
427
+
428
+ return context.json({ success: true })
429
+ })
430
+
431
+ /** DELETE /api/seeds/:slug/branches/:branchId — drop a single field column. */
432
+ seedsApp.delete('/:slug/branches/:branchId', async (context) => {
433
+ const slug = context.req.param('slug')
434
+ const branchId = context.req.param('branchId')
435
+ const body = await parseJsonBody(context)
436
+ if (body instanceof Response) return body
437
+
438
+ const existing = await getActiveSeed(context, slug)
439
+ if (existing instanceof Response) return existing
440
+
441
+ const branch = existing.definition.branches.find((b: Branch) => b.id === branchId)
442
+ if (!branch) {
443
+ return publicProblem(context, { type: 'branch-not-found', title: 'Branch not found', status: 404, detail: `No branch with id '${branchId}' in seed '${slug}'.` })
444
+ }
445
+
446
+ const confirmErr = requireConfirm(context, `${slug}.${branch.alias}`, body)
447
+ if (confirmErr) return confirmErr
448
+
449
+ const updatedDef: Seed = {
450
+ ...existing.definition,
451
+ branches: existing.definition.branches.filter((b: Branch) => b.id !== branchId),
452
+ }
453
+
454
+ const stmts = generateDropColumn(existing.definition, branch.alias)
455
+ const error = await applyDestructiveSeedDef(context, slug, updatedDef, stmts, { op: 'drop-branch', branchId, alias: branch.alias })
456
+ if (error) return error
457
+
458
+ return context.json({ success: true })
459
+ })
460
+
461
+ /** PATCH /api/seeds/:slug/branches/:branchId/rename — rename a field alias. */
462
+ seedsApp.patch('/:slug/branches/:branchId/rename', async (context) => {
463
+ const slug = context.req.param('slug')
464
+ const branchId = context.req.param('branchId')
465
+ const body = await parseJsonBody(context)
466
+ if (body instanceof Response) return body
467
+
468
+ const newAlias = (body as Record<string, unknown>)?.newAlias
469
+ if (typeof newAlias !== 'string' || !BRANCH_ALIAS_RE.test(newAlias)) {
470
+ return publicProblem(context, { type: 'invalid-json', title: 'Bad Request', status: 400, detail: `newAlias must match ${BRANCH_ALIAS_RE.source} (lowercase letter followed by alphanumeric characters or underscores).` })
471
+ }
472
+
473
+ const existing = await getActiveSeed(context, slug)
474
+ if (existing instanceof Response) return existing
475
+
476
+ const branch = existing.definition.branches.find((b: Branch) => b.id === branchId)
477
+ if (!branch) {
478
+ return publicProblem(context, { type: 'branch-not-found', title: 'Branch not found', status: 404, detail: `No branch with id '${branchId}' in seed '${slug}'.` })
479
+ }
480
+
481
+ const confirmErr = requireConfirm(context, `${slug}.${branch.alias}`, body)
482
+ if (confirmErr) return confirmErr
483
+
484
+ const renamedDef: Seed = {
485
+ ...existing.definition,
486
+ branches: existing.definition.branches.map((b: Branch) =>
487
+ b.id === branchId ? { ...b, alias: newAlias } : b
488
+ ),
489
+ }
490
+
491
+ const renameStmts = generateRenameColumn(existing.definition, branch.alias, newAlias)
492
+ const ftsStmts = planFtsRebuild(renamedDef)
493
+
494
+ const error = await applyDestructiveSeedDef(context, slug, renamedDef, [...renameStmts, ...ftsStmts], { op: 'rename-branch', branchId, from: branch.alias, to: newAlias })
495
+ if (error) return error
496
+
497
+ let affectedAutomations: string[] = []
498
+ try {
499
+ const automationRepo = context.get('automationRepository')
500
+ if (automationRepo) {
501
+ const automations = await automationRepo.list(slug)
502
+ const oldAlias = branch.alias
503
+ affectedAutomations = automations
504
+ .filter((a: any) => JSON.stringify(a).includes(oldAlias))
505
+ .map((a: any) => a.id)
506
+ }
507
+ } catch { /* non-fatal */ }
508
+
509
+ return context.json({ success: true, affectedAutomations })
510
+ })
511
+
512
+ /** PATCH /api/seeds/:slug/branches/:branchId/retype — change field SQL type. */
513
+ seedsApp.patch('/:slug/branches/:branchId/retype', async (context) => {
514
+ const slug = context.req.param('slug')
515
+ const branchId = context.req.param('branchId')
516
+ const body = await parseJsonBody(context)
517
+ if (body instanceof Response) return body
518
+
519
+ const newType = (body as Record<string, unknown>)?.newType
520
+ const VALID_TYPES = new Set(['text','number','boolean','date','json','richtext','file','tags','relation','repeater'])
521
+ if (typeof newType !== 'string' || !VALID_TYPES.has(newType)) {
522
+ return publicProblem(context, { type: 'invalid-json', title: 'Bad Request', status: 400, detail: `newType must be one of: ${[...VALID_TYPES].join(', ')}.` })
523
+ }
524
+
525
+ const existing = await getActiveSeed(context, slug)
526
+ if (existing instanceof Response) return existing
527
+
528
+ const branch = existing.definition.branches.find((b: Branch) => b.id === branchId)
529
+ if (!branch) {
530
+ return publicProblem(context, { type: 'branch-not-found', title: 'Branch not found', status: 404, detail: `No branch with id '${branchId}' in seed '${slug}'.` })
531
+ }
532
+
533
+ if (branch.type === 'repeater' || newType === 'repeater') {
534
+ return publicProblem(context, {
535
+ type: 'retype-not-supported',
536
+ title: 'Retype not supported',
537
+ status: 422,
538
+ detail: "Retyping to or from 'repeater' is not supported in v1.",
539
+ })
540
+ }
541
+
542
+ const confirmErr = requireConfirm(context, `${slug}.${branch.alias}`, body)
543
+ if (confirmErr) return confirmErr
544
+
545
+ const retypedBranch: Branch = { ...branch, type: newType as Branch['type'] }
546
+ const retypedDef: Seed = {
547
+ ...existing.definition,
548
+ branches: existing.definition.branches.map((b: Branch) => b.id === branchId ? retypedBranch : b),
549
+ }
550
+
551
+ const retypeStmts = generateRetypeColumn(existing.definition, retypedBranch)
552
+ const ftsStmts = planFtsRebuild(retypedDef)
553
+
554
+ const error = await applyDestructiveSeedDef(context, slug, retypedDef, [...retypeStmts, ...ftsStmts], { op: 'retype-branch', branchId, from: branch.type, to: newType })
555
+ if (error) return error
556
+
557
+ return context.json({ success: true })
558
+ })