@beechcms/api 0.6.0-preview.1 → 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 (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
@@ -1,693 +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
- /** Assign ids to branches that are missing one, mutates each branch in place. */
50
- function assignBranchIds(branches: Branch[], accSeed: Pick<Seed, 'branches'>): void {
51
- for (const branch of branches) {
52
- if (!branch.id) {
53
- branch.id = nextBranchId(accSeed)
54
- ;(accSeed.branches as Branch[]).push(branch)
55
- }
56
- }
57
- }
58
-
59
- export const seedsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
60
-
61
- /** GET /api/seeds — list all seed records (active + deleted). Admin-only. */
62
- seedsApp.get('/', async (context) => {
63
- const denied = requireAdmin(context)
64
- if (denied) return denied
65
- const records = await context.get('seedRepository').listAll()
66
- return context.json(records)
67
- })
68
-
69
- /** GET /api/seeds/:slug single record by slug. Admin-only. */
70
- seedsApp.get('/:slug', async (context) => {
71
- const denied = requireAdmin(context)
72
- if (denied) return denied
73
- const slug = context.req.param('slug')
74
- const record = await context.get('seedRepository').get(slug)
75
- if (!record) {
76
- return publicProblem(context, {
77
- type: 'seed-not-found',
78
- title: 'Seed not found',
79
- status: 404,
80
- detail: `No seed with slug '${slug}'.`,
81
- })
82
- }
83
- return context.json(record)
84
- })
85
-
86
- /** POST /api/seeds — create a new content type. */
87
- seedsApp.post('/', async (context) => {
88
- const denied = requireAdmin(context)
89
- if (denied) return denied
90
-
91
- let body: unknown
92
- try { body = await context.req.json() } catch {
93
- return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
94
- }
95
-
96
- const candidate = body as Seed
97
- const slug = candidate?.slug
98
-
99
- if (!slug || !SLUG_RE.test(slug)) {
100
- return publicProblem(context, { type: 'invalid-json', title: 'Bad Request', status: 400, detail: `slug must match ${SLUG_RE.source}.` })
101
- }
102
-
103
- const repo = context.get('seedRepository')
104
- const existing = await repo.get(slug)
105
-
106
- if (existing && existing.status === 'active') {
107
- return publicProblem(context, {
108
- type: 'slug-conflict',
109
- title: 'Slug conflict',
110
- status: 409,
111
- detail: `An active seed with slug '${slug}' already exists.`,
112
- })
113
- }
114
- // If deleted, treat as revive+replace (upsert reactivates). Content table reused (additive).
115
-
116
- // Assign ids to branches that are missing one
117
- const accSeed: Pick<Seed, 'branches'> = { branches: [] }
118
- const branches: Branch[] = Array.isArray(candidate.branches) ? candidate.branches : []
119
- for (const branch of branches) {
120
- if (!branch.id) {
121
- branch.id = nextBranchId(accSeed)
122
- }
123
- ;(accSeed.branches as Branch[]).push({ ...branch })
124
- }
125
-
126
- // Default displayNameAlias to first text branch if not provided
127
- if (!candidate.displayNameAlias) {
128
- const firstText = branches.find(b => b.type === 'text')
129
- if (!firstText) {
130
- 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).' })
131
- }
132
- candidate.displayNameAlias = firstText.alias
133
- }
134
- candidate.branches = branches
135
-
136
- // Validate against full active set
137
- const activeSeeds = await repo.listActive()
138
- const candidateSet = [...activeSeeds.filter(s => s.slug !== slug), candidate]
139
- const issues = validateSeedDefinitions(candidateSet)
140
- const fatalIssues = issues.filter(i => i.fatal && i.slug === slug)
141
- if (fatalIssues.length > 0) {
142
- return publicProblem(context, {
143
- type: 'validation-failed',
144
- title: 'Validation failed',
145
- status: 422,
146
- detail: fatalIssues.flatMap(i => i.messages).join('; '),
147
- })
148
- }
149
-
150
- const schemaMutator = context.get('schemaMutator')
151
- const tableName = `content_${slug}`
152
- const existingCols = await schemaMutator.getColumns(tableName)
153
-
154
- // Order: validate → execDdl → upsert → bump → log
155
- try {
156
- const stmts = existingCols === null
157
- ? planCreateSeed(candidate)
158
- : planExtendSeed(candidate, existingCols).statements
159
- await schemaMutator.execDdl(stmts)
160
- } catch (err) {
161
- return publicProblem(context, {
162
- type: 'ddl-failed',
163
- title: 'DDL execution failed',
164
- status: 422,
165
- detail: internalErrorDetail(context.env, err),
166
- })
167
- }
168
-
169
- await repo.upsert(slug, candidate, 'runtime')
170
- await repo.bumpRegistryVersion()
171
-
172
- const actor = actorFromContext(context)
173
- context.get('activityLogger').log({ action: 'create', entityType: 'seed', entityId: slug, details: { slug }, actor })
174
-
175
- return context.json({ slug }, 201)
176
- })
177
-
178
- /** PUT /api/seeds/:slug — replace definition (additive-only). */
179
- seedsApp.put('/:slug', async (context) => {
180
- const denied = requireAdmin(context)
181
- if (denied) return denied
182
-
183
- const slug = context.req.param('slug')
184
- let body: unknown
185
- try { body = await context.req.json() } catch {
186
- return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
187
- }
188
-
189
- const repo = context.get('seedRepository')
190
- const existing = await repo.get(slug)
191
- if (!existing || existing.status === 'deleted') {
192
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
193
- }
194
-
195
- const incoming = body as Seed
196
- const storedBranches = existing.definition.branches
197
- const incomingBranches: Branch[] = Array.isArray(incoming.branches) ? incoming.branches : []
198
-
199
- // Build map of stored branches by id
200
- const storedById = new Map(storedBranches.map(b => [b.id, b]))
201
-
202
- for (const branch of incomingBranches) {
203
- const stored = branch.id ? storedById.get(branch.id) : undefined
204
- if (stored) {
205
- // Alias rename check (same id, different alias)
206
- if (branch.alias !== stored.alias) {
207
- return publicProblem(context, {
208
- type: 'alias-rename-not-supported',
209
- title: 'Alias rename not supported',
210
- status: 422,
211
- 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.`,
212
- })
213
- }
214
- // Type change check
215
- if (branch.type !== stored.type) {
216
- return publicProblem(context, {
217
- type: 'branch-type-change-not-supported',
218
- title: 'Branch type change not supported',
219
- status: 422,
220
- 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.`,
221
- })
222
- }
223
- } else if (!branch.id) {
224
- // New branch — assign id
225
- const accSeed = { branches: incomingBranches.filter(b => b.id) }
226
- branch.id = nextBranchId(accSeed)
227
- }
228
- }
229
-
230
- const candidate: Seed = { ...incoming, slug, branches: incomingBranches }
231
-
232
- const activeSeeds = await repo.listActive()
233
- const candidateSet = [...activeSeeds.filter(s => s.slug !== slug), candidate]
234
- const issues = validateSeedDefinitions(candidateSet)
235
- const fatalIssues = issues.filter(i => i.fatal && i.slug === slug)
236
- if (fatalIssues.length > 0) {
237
- return publicProblem(context, {
238
- type: 'validation-failed',
239
- title: 'Validation failed',
240
- status: 422,
241
- detail: fatalIssues.flatMap(i => i.messages).join('; '),
242
- })
243
- }
244
-
245
- const schemaMutator = context.get('schemaMutator')
246
- const tableName = `content_${slug}`
247
- const existingCols = await schemaMutator.getColumns(tableName)
248
-
249
- try {
250
- const stmts = existingCols === null
251
- ? planCreateSeed(candidate)
252
- : planExtendSeed(candidate, existingCols).statements
253
- await schemaMutator.execDdl(stmts)
254
- } catch (err) {
255
- return publicProblem(context, {
256
- type: 'ddl-failed',
257
- title: 'DDL execution failed',
258
- status: 422,
259
- detail: internalErrorDetail(context.env, err),
260
- })
261
- }
262
-
263
- await repo.upsert(slug, candidate, 'runtime')
264
- await repo.bumpRegistryVersion()
265
-
266
- const actor = actorFromContext(context)
267
- context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: { slug }, actor })
268
-
269
- return context.json({ slug })
270
- })
271
-
272
- /** POST /api/seeds/:slug/branches add a single branch. */
273
- seedsApp.post('/:slug/branches', async (context) => {
274
- const denied = requireAdmin(context)
275
- if (denied) return denied
276
-
277
- const slug = context.req.param('slug')
278
- let body: unknown
279
- try { body = await context.req.json() } catch {
280
- return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
281
- }
282
-
283
- const repo = context.get('seedRepository')
284
- const existing = await repo.get(slug)
285
- if (!existing || existing.status === 'deleted') {
286
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
287
- }
288
-
289
- const newBranch = body as Branch
290
- newBranch.id = nextBranchId(existing.definition)
291
-
292
- const candidate: Seed = {
293
- ...existing.definition,
294
- branches: [...existing.definition.branches, newBranch],
295
- }
296
-
297
- const activeSeeds = await repo.listActive()
298
- const candidateSet = [...activeSeeds.filter(s => s.slug !== slug), candidate]
299
- const issues = validateSeedDefinitions(candidateSet)
300
- const fatalIssues = issues.filter(i => i.fatal && i.slug === slug)
301
- if (fatalIssues.length > 0) {
302
- return publicProblem(context, {
303
- type: 'validation-failed',
304
- title: 'Validation failed',
305
- status: 422,
306
- detail: fatalIssues.flatMap(i => i.messages).join('; '),
307
- })
308
- }
309
-
310
- const schemaMutator = context.get('schemaMutator')
311
- const tableName = `content_${slug}`
312
- const existingCols = await schemaMutator.getColumns(tableName)
313
-
314
- try {
315
- const stmts = existingCols === null
316
- ? planCreateSeed(candidate)
317
- : planExtendSeed(candidate, existingCols).statements
318
- await schemaMutator.execDdl(stmts)
319
- } catch (err) {
320
- return publicProblem(context, {
321
- type: 'ddl-failed',
322
- title: 'DDL execution failed',
323
- status: 422,
324
- detail: internalErrorDetail(context.env, err),
325
- })
326
- }
327
-
328
- await repo.upsert(slug, candidate, 'runtime')
329
- await repo.bumpRegistryVersion()
330
-
331
- const actor = actorFromContext(context)
332
- context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: { addedBranch: newBranch.id }, actor })
333
-
334
- return context.json({ id: newBranch.id })
335
- })
336
-
337
- /** DELETE /api/seeds/:slug — soft-delete a content type. */
338
- seedsApp.delete('/:slug', async (context) => {
339
- const denied = requireAdmin(context)
340
- if (denied) return denied
341
-
342
- const slug = context.req.param('slug')
343
- const repo = context.get('seedRepository')
344
- const existing = await repo.get(slug)
345
-
346
- if (!existing || existing.status === 'deleted') {
347
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
348
- }
349
-
350
- // Backref guard: reject if other active seeds reference this slug via relation branches
351
- const backrefMap = context.get('backrefMap')
352
- const inbound = backrefMap.get(slug)
353
- if (inbound && inbound.length > 0) {
354
- const referencers = [...new Set(inbound.map((r) => r.sourceSlug))]
355
- return publicProblem(context, {
356
- type: 'seed-referenced',
357
- title: 'Seed referenced',
358
- status: 409,
359
- detail: `Seed '${slug}' is referenced by: ${referencers.join(', ')}. Remove those relations first.`,
360
- })
361
- }
362
-
363
- await repo.softDelete(slug)
364
- await repo.bumpRegistryVersion()
365
-
366
- const actor = actorFromContext(context)
367
- context.get('activityLogger').log({ action: 'delete', entityType: 'seed', entityId: slug, details: { slug }, actor })
368
-
369
- return context.json({ success: true })
370
- })
371
-
372
- // ─── Sprint 06: Destructive Danger Zone routes ────────────────────────────────
373
- // All destructive ops require a typed confirmation body `{ confirm: "<slug>" }`
374
- // (or `"<slug>.<alias>"` for field-level ops) that the handler checks before
375
- // proceeding. Mismatch 400 confirmation-required.
376
- //
377
- // None of these routes must ever call execDdl — they go through the dedicated
378
- // ISchemaMutator destructive methods (dropTable / dropColumn / renameColumn /
379
- // execDestructive) which are the only sanctioned channel for DROP / RENAME.
380
-
381
- /** Typed confirm guard. Returns a 400 response on mismatch, null on success. */
382
- function requireConfirm(context: any, expected: string, body: unknown) {
383
- const confirm = (body as Record<string, unknown>)?.confirm
384
- if (typeof confirm !== 'string' || confirm !== expected) {
385
- return publicProblem(context, {
386
- type: 'confirmation-required',
387
- title: 'Confirmation required',
388
- status: 400,
389
- detail: `Destructive operation requires body field \`confirm\` equal to "${expected}".`,
390
- })
391
- }
392
- return null
393
- }
394
-
395
- /** GET /api/seeds/:slug/orphans columns in DB but absent from the definition. */
396
- seedsApp.get('/:slug/orphans', async (context) => {
397
- const denied = requireAdmin(context)
398
- if (denied) return denied
399
-
400
- const slug = context.req.param('slug')
401
- const repo = context.get('seedRepository')
402
- const existing = await repo.get(slug)
403
- if (!existing || existing.status === 'deleted') {
404
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
405
- }
406
-
407
- const schemaMutator = context.get('schemaMutator')
408
- const dbCols = await schemaMutator.getColumns(`content_${slug}`)
409
- if (!dbCols) return context.json({ orphans: [] })
410
-
411
- const knownAliases = new Set([
412
- 'id', 'slug', 'status', 'created_at', 'updated_at',
413
- ...existing.definition.branches
414
- .filter((b: Branch) => !(b.type === 'relation' && b.multiple === true))
415
- .map((b: Branch) => b.alias),
416
- ])
417
- const orphans = [...dbCols].filter(col => !knownAliases.has(col))
418
- return context.json({ orphans })
419
- })
420
-
421
- /** POST /api/seeds/:slug/fts/rebuild — rebuild FTS table + triggers. */
422
- seedsApp.post('/:slug/fts/rebuild', async (context) => {
423
- const denied = requireAdmin(context)
424
- if (denied) return denied
425
-
426
- const slug = context.req.param('slug')
427
- const repo = context.get('seedRepository')
428
- const existing = await repo.get(slug)
429
- if (!existing || existing.status === 'deleted') {
430
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
431
- }
432
-
433
- const stmts = planFtsRebuild(existing.definition)
434
- if (stmts.length > 0) {
435
- await context.get('schemaMutator').execDestructive(stmts)
436
- }
437
-
438
- const actor = actorFromContext(context)
439
- context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: { op: 'fts-rebuild' }, actor })
440
-
441
- return context.json({ success: true })
442
- })
443
-
444
- /** DELETE /api/seeds/:slug/hard — hard delete: drops tables + deletes row. */
445
- seedsApp.delete('/:slug/hard', async (context) => {
446
- const denied = requireAdmin(context)
447
- if (denied) return denied
448
-
449
- const slug = context.req.param('slug')
450
- let body: unknown
451
- try { body = await context.req.json() } catch {
452
- return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
453
- }
454
-
455
- const confirmErr = requireConfirm(context, slug, body)
456
- if (confirmErr) return confirmErr
457
-
458
- const repo = context.get('seedRepository')
459
- const existing = await repo.get(slug)
460
- if (!existing || existing.status === 'deleted') {
461
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
462
- }
463
-
464
- // Backref guard — same logic as soft-delete.
465
- const backrefMap = context.get('backrefMap')
466
- const inbound = backrefMap.get(slug)
467
- if (inbound && inbound.length > 0) {
468
- const referencers = [...new Set(inbound.map((r: any) => r.sourceSlug))]
469
- return publicProblem(context, {
470
- type: 'seed-referenced',
471
- title: 'Seed referenced',
472
- status: 409,
473
- detail: `Seed '${slug}' is referenced by: ${referencers.join(', ')}. Remove those relations first.`,
474
- })
475
- }
476
-
477
- const schemaMutator = context.get('schemaMutator')
478
- const seed = existing.definition
479
-
480
- // R2 cascade — enumerate file branches, select values, delete best-effort.
481
- const fileBranches = seed.branches.filter((b: Branch) => b.type === 'file')
482
- if (fileBranches.length > 0) {
483
- try {
484
- const dbCols = await schemaMutator.getColumns(`content_${slug}`)
485
- if (dbCols) {
486
- const colList = fileBranches.map((b: Branch) => b.alias).filter((a: string) => dbCols.has(a)).join(', ')
487
- if (colList) {
488
- const rs = await context.env.DB.prepare(`SELECT ${colList} FROM content_${slug}`).all<Record<string, unknown>>()
489
- const rows = rs.results ?? []
490
- const r2Keys: string[] = []
491
- for (const row of rows) {
492
- for (const b of fileBranches) {
493
- const val = row[b.alias]
494
- if (!val) continue
495
- const keys = extractMediaKeysFromData(seed, { [b.alias]: val })
496
- r2Keys.push(...keys)
497
- }
498
- }
499
- if (r2Keys.length > 0) {
500
- await deleteR2Objects(context, r2Keys).catch(() => { /* non-fatal */ })
501
- }
502
- }
503
- }
504
- } catch { /* non-fatal: drop proceeds */ }
505
- }
506
-
507
- // Drop tables (junction drafts → fts → main).
508
- await schemaMutator.execDestructive(generateDropTable(seed))
509
-
510
- // Remove the seeds row.
511
- await repo.hardDelete(slug)
512
- await repo.bumpRegistryVersion()
513
-
514
- const actor = actorFromContext(context)
515
- context.get('activityLogger').log({ action: 'delete', entityType: 'seed', entityId: slug, details: { op: 'hard-delete', slug }, actor })
516
-
517
- return context.json({ success: true })
518
- })
519
-
520
- /** DELETE /api/seeds/:slug/branches/:branchId drop a single field column. */
521
- seedsApp.delete('/:slug/branches/:branchId', async (context) => {
522
- const denied = requireAdmin(context)
523
- if (denied) return denied
524
-
525
- const slug = context.req.param('slug')
526
- const branchId = context.req.param('branchId')
527
- let body: unknown
528
- try { body = await context.req.json() } catch {
529
- return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
530
- }
531
-
532
- const repo = context.get('seedRepository')
533
- const existing = await repo.get(slug)
534
- if (!existing || existing.status === 'deleted') {
535
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
536
- }
537
-
538
- const branch = existing.definition.branches.find((b: Branch) => b.id === branchId)
539
- if (!branch) {
540
- return publicProblem(context, { type: 'branch-not-found', title: 'Branch not found', status: 404, detail: `No branch with id '${branchId}' in seed '${slug}'.` })
541
- }
542
-
543
- const confirmErr = requireConfirm(context, `${slug}.${branch.alias}`, body)
544
- if (confirmErr) return confirmErr
545
-
546
- const schemaMutator = context.get('schemaMutator')
547
- try {
548
- await schemaMutator.execDestructive(generateDropColumn(existing.definition, branch.alias))
549
- } catch (err) {
550
- return publicProblem(context, { type: 'ddl-failed', title: 'DDL failed', status: 422, detail: internalErrorDetail(context.env, err) })
551
- }
552
-
553
- // Remove the branch from the definition.
554
- const updatedDef: Seed = {
555
- ...existing.definition,
556
- branches: existing.definition.branches.filter((b: Branch) => b.id !== branchId),
557
- }
558
- await repo.upsert(slug, updatedDef, 'runtime')
559
- await repo.bumpRegistryVersion()
560
-
561
- const actor = actorFromContext(context)
562
- context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: { op: 'drop-branch', branchId, alias: branch.alias }, actor })
563
-
564
- return context.json({ success: true })
565
- })
566
-
567
- /** PATCH /api/seeds/:slug/branches/:branchId/rename — rename a field alias. */
568
- seedsApp.patch('/:slug/branches/:branchId/rename', async (context) => {
569
- const denied = requireAdmin(context)
570
- if (denied) return denied
571
-
572
- const slug = context.req.param('slug')
573
- const branchId = context.req.param('branchId')
574
- let body: unknown
575
- try { body = await context.req.json() } catch {
576
- return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
577
- }
578
-
579
- const newAlias = (body as Record<string, unknown>)?.newAlias
580
- if (typeof newAlias !== 'string' || !BRANCH_ALIAS_RE.test(newAlias)) {
581
- return publicProblem(context, { type: 'invalid-json', title: 'Bad Request', status: 400, detail: `newAlias must match ${BRANCH_ALIAS_RE.source} (lowercase letter followed by lowercase letters, digits or underscores).` })
582
- }
583
-
584
- const repo = context.get('seedRepository')
585
- const existing = await repo.get(slug)
586
- if (!existing || existing.status === 'deleted') {
587
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
588
- }
589
-
590
- const branch = existing.definition.branches.find((b: Branch) => b.id === branchId)
591
- if (!branch) {
592
- return publicProblem(context, { type: 'branch-not-found', title: 'Branch not found', status: 404, detail: `No branch with id '${branchId}' in seed '${slug}'.` })
593
- }
594
-
595
- const confirmErr = requireConfirm(context, `${slug}.${branch.alias}`, body)
596
- if (confirmErr) return confirmErr
597
-
598
- const schemaMutator = context.get('schemaMutator')
599
-
600
- // Rename column (+ drafts mirror). Then rebuild FTS if the branch is searchable.
601
- const renamedDef: Seed = {
602
- ...existing.definition,
603
- branches: existing.definition.branches.map((b: Branch) =>
604
- b.id === branchId ? { ...b, alias: newAlias } : b
605
- ),
606
- }
607
-
608
- const renameStmts = generateRenameColumn(existing.definition, branch.alias, newAlias)
609
- const ftsStmts = planFtsRebuild(renamedDef)
610
-
611
- try {
612
- await schemaMutator.execDestructive([...renameStmts, ...ftsStmts])
613
- } catch (err) {
614
- return publicProblem(context, { type: 'ddl-failed', title: 'DDL failed', status: 422, detail: internalErrorDetail(context.env, err) })
615
- }
616
-
617
- // Scan automations for references to the old alias and warn.
618
- let affectedAutomations: string[] = []
619
- try {
620
- const automationRepo = context.get('automationRepository')
621
- if (automationRepo) {
622
- const automations = await automationRepo.list(slug)
623
- const oldAlias = branch.alias
624
- affectedAutomations = automations
625
- .filter((a: any) => JSON.stringify(a).includes(oldAlias))
626
- .map((a: any) => a.id)
627
- }
628
- } catch { /* non-fatal */ }
629
-
630
- await repo.upsert(slug, renamedDef, 'runtime')
631
- await repo.bumpRegistryVersion()
632
-
633
- const actor = actorFromContext(context)
634
- context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: { op: 'rename-branch', branchId, from: branch.alias, to: newAlias }, actor })
635
-
636
- return context.json({ success: true, affectedAutomations })
637
- })
638
-
639
- /** PATCH /api/seeds/:slug/branches/:branchId/retype — change field SQL type. */
640
- seedsApp.patch('/:slug/branches/:branchId/retype', async (context) => {
641
- const denied = requireAdmin(context)
642
- if (denied) return denied
643
-
644
- const slug = context.req.param('slug')
645
- const branchId = context.req.param('branchId')
646
- let body: unknown
647
- try { body = await context.req.json() } catch {
648
- return publicProblem(context, { type: 'invalid-json', title: 'Invalid JSON', status: 400, detail: 'Body must be valid JSON.' })
649
- }
650
-
651
- const newType = (body as Record<string, unknown>)?.newType
652
- const VALID_TYPES = new Set(['text','number','boolean','date','json','richtext','file','tags','relation'])
653
- if (typeof newType !== 'string' || !VALID_TYPES.has(newType)) {
654
- return publicProblem(context, { type: 'invalid-json', title: 'Bad Request', status: 400, detail: `newType must be one of: ${[...VALID_TYPES].join(', ')}.` })
655
- }
656
-
657
- const repo = context.get('seedRepository')
658
- const existing = await repo.get(slug)
659
- if (!existing || existing.status === 'deleted') {
660
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed not found', status: 404, detail: `No active seed with slug '${slug}'.` })
661
- }
662
-
663
- const branch = existing.definition.branches.find((b: Branch) => b.id === branchId)
664
- if (!branch) {
665
- return publicProblem(context, { type: 'branch-not-found', title: 'Branch not found', status: 404, detail: `No branch with id '${branchId}' in seed '${slug}'.` })
666
- }
667
-
668
- const confirmErr = requireConfirm(context, `${slug}.${branch.alias}`, body)
669
- if (confirmErr) return confirmErr
670
-
671
- const retypedBranch: Branch = { ...branch, type: newType as Branch['type'] }
672
- const retypedDef: Seed = {
673
- ...existing.definition,
674
- branches: existing.definition.branches.map((b: Branch) => b.id === branchId ? retypedBranch : b),
675
- }
676
-
677
- const retypeStmts = generateRetypeColumn(existing.definition, retypedBranch)
678
- const ftsStmts = planFtsRebuild(retypedDef)
679
-
680
- try {
681
- await context.get('schemaMutator').execDestructive([...retypeStmts, ...ftsStmts])
682
- } catch (err) {
683
- return publicProblem(context, { type: 'ddl-failed', title: 'DDL failed', status: 422, detail: internalErrorDetail(context.env, err) })
684
- }
685
-
686
- await repo.upsert(slug, retypedDef, 'runtime')
687
- await repo.bumpRegistryVersion()
688
-
689
- const actor = actorFromContext(context)
690
- context.get('activityLogger').log({ action: 'update', entityType: 'seed', entityId: slug, details: { op: 'retype-branch', branchId, from: branch.type, to: newType }, actor })
691
-
692
- return context.json({ success: true })
693
- })
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
+ })