@beechcms/api 0.4.3 → 0.6.0-preview.1

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 (207) hide show
  1. package/assets/dashboard/Searching.svg +1 -0
  2. package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
  3. package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
  4. package/assets/dashboard/index.html +2 -2
  5. package/assets/dashboard/noResult.svg +43 -0
  6. package/assets/dashboard/working.svg +1 -0
  7. package/migrations/0000_v040_base.sql +27 -0
  8. package/migrations/0030_test_seeds.sql +125 -0
  9. package/package.json +14 -9
  10. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
  11. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
  12. package/src/auth/bcrypt-hash-provider.ts +6 -2
  13. package/src/auth/constants.ts +4 -0
  14. package/src/auth/generate-refresh-token.test.ts +8 -4
  15. package/src/auth/hash-provider.test.ts +14 -5
  16. package/src/auth/jose-token-service.ts +7 -0
  17. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  18. package/src/auth/login.test.ts +9 -1
  19. package/src/auth/login.ts +5 -1
  20. package/src/auth/refresh.ts +7 -1
  21. package/src/auth/token-service.test.ts +9 -1
  22. package/src/factory.ts +37 -16
  23. package/src/features/automations/__tests__/action-executors.test.ts +59 -72
  24. package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
  25. package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
  26. package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
  27. package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
  28. package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
  29. package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
  30. package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
  31. package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
  32. package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
  33. package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
  34. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  35. package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
  36. package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
  37. package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
  38. package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
  39. package/src/features/automations/action-executors/index.ts +5 -1
  40. package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
  41. package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
  42. package/src/features/automations/action-executors/webhook.executor.ts +55 -13
  43. package/src/features/automations/automation-runner.ts +4 -0
  44. package/src/features/automations/automation-runner.utils.ts +4 -0
  45. package/src/features/automations/automations.handler.ts +4 -0
  46. package/src/features/automations/automations.schema.ts +19 -2
  47. package/src/features/automations/context-resolver.ts +4 -0
  48. package/src/features/automations/cron-runner.ts +4 -0
  49. package/src/features/automations/cron-runner.utils.ts +4 -0
  50. package/src/features/automations/filter-translation.ts +4 -0
  51. package/src/features/automations/index.ts +4 -0
  52. package/src/features/automations/template-grammar.ts +4 -0
  53. package/src/features/automations/var-access-resolver.ts +4 -0
  54. package/src/features/automations/when-evaluator.ts +4 -0
  55. package/src/features/automations/when-pushdown.ts +4 -0
  56. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  57. package/src/features/backrefs/backrefs.handler.ts +168 -0
  58. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  59. package/src/features/backrefs/index.ts +5 -0
  60. package/src/features/content/constants.ts +6 -0
  61. package/src/features/content/handlers/bulk.handler.ts +185 -0
  62. package/src/features/content/handlers/create.ts +19 -55
  63. package/src/features/content/handlers/delete.ts +12 -37
  64. package/src/features/content/handlers/facets.ts +4 -0
  65. package/src/features/content/handlers/get.ts +4 -0
  66. package/src/features/content/handlers/helpers.test.ts +180 -0
  67. package/src/features/content/handlers/helpers.ts +93 -0
  68. package/src/features/content/handlers/list.ts +105 -19
  69. package/src/features/content/handlers/update.ts +19 -64
  70. package/src/features/content/index.ts +6 -0
  71. package/src/features/draft/draft.handler.ts +33 -8
  72. package/src/features/draft/draft.middleware.ts +4 -0
  73. package/src/features/draft/index.ts +4 -0
  74. package/src/features/email/email.provider.ts +4 -0
  75. package/src/features/email/email.service.ts +50 -45
  76. package/src/features/email/email.types.ts +12 -1
  77. package/src/features/email/index.ts +4 -0
  78. package/src/features/email/providers/resend.ts +4 -0
  79. package/src/features/email/providers/smtp.ts +55 -0
  80. package/src/features/email/templates/automation-mail.ts +4 -0
  81. package/src/features/email/templates/password-changed.ts +4 -0
  82. package/src/features/email/templates/password-reset.ts +4 -0
  83. package/src/features/email/templates/shell.ts +4 -0
  84. package/src/features/notifications/index.ts +4 -0
  85. package/src/features/notifications/notifications.handler.ts +4 -0
  86. package/src/features/password-reset/index.ts +5 -1
  87. package/src/features/password-reset/request.ts +12 -2
  88. package/src/features/password-reset/reset.ts +12 -2
  89. package/src/features/rotate-field/index.ts +4 -0
  90. package/src/features/rotate-field/rotate-field.handler.ts +4 -0
  91. package/src/features/rotate-field/rotate-field.schema.ts +4 -0
  92. package/src/features/schema/schema.handler.ts +118 -3
  93. package/src/features/seeds/index.ts +5 -0
  94. package/src/features/seeds/seeds.handler.test.ts +632 -0
  95. package/src/features/seeds/seeds.handler.ts +693 -0
  96. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  97. package/src/features/settings/settings.handler.ts +98 -8
  98. package/src/features/setup/index.ts +124 -11
  99. package/src/features/stats/index.ts +4 -0
  100. package/src/features/stats/stats.handler.ts +11 -21
  101. package/src/features/webhooks/index.ts +63 -0
  102. package/src/index.ts +28 -19
  103. package/src/media-utils.ts +4 -0
  104. package/src/middleware/auth-providers.middleware.ts +13 -0
  105. package/src/middleware/observability.middleware.ts +21 -3
  106. package/src/middleware/rate-limit.middleware.ts +4 -0
  107. package/src/middleware/repository.middleware.ts +25 -2
  108. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  109. package/src/middleware/seed-registry.middleware.ts +21 -0
  110. package/src/middleware/storage.middleware.ts +4 -0
  111. package/src/middleware.ts +5 -7
  112. package/src/public/access-policy.ts +4 -0
  113. package/src/public/api-key-middleware.ts +4 -0
  114. package/src/public/cache-utils.ts +38 -34
  115. package/src/public/entry-projection.ts +46 -42
  116. package/src/public/idempotency.ts +23 -19
  117. package/src/public/index.ts +4 -0
  118. package/src/public/problem-details.ts +71 -0
  119. package/src/public/public-add.ts +4 -0
  120. package/src/public/public-edit.ts +4 -0
  121. package/src/public/public-errors.ts +4 -0
  122. package/src/public/public-read.ts +4 -0
  123. package/src/public/public-routes.ts +4 -0
  124. package/src/public/query-builder.test.ts +4 -0
  125. package/src/public/query-builder.ts +4 -0
  126. package/src/public/rate-limit-middleware.ts +4 -0
  127. package/src/public/read-list.ts +54 -50
  128. package/src/public/read-single.ts +48 -44
  129. package/src/public/response-builder.ts +4 -0
  130. package/src/public/sanitize.ts +4 -0
  131. package/src/public/slug-utils.ts +4 -0
  132. package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
  133. package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
  134. package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
  135. package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
  136. package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
  137. package/src/rate-limit/no-op-rate-limiter.ts +4 -0
  138. package/src/search-utils.test.ts +4 -0
  139. package/src/search-utils.ts +5 -1
  140. package/src/search.ts +5 -1
  141. package/src/shared/apply-policies.test.ts +4 -0
  142. package/src/shared/apply-policies.ts +4 -0
  143. package/src/shared/automations.repository.d1.ts +4 -0
  144. package/src/shared/background-notification-service.test.ts +4 -0
  145. package/src/shared/background-notification-service.ts +4 -0
  146. package/src/shared/base.repository.d1.ts +4 -0
  147. package/src/shared/content-utils.test.ts +4 -0
  148. package/src/shared/content-utils.ts +4 -0
  149. package/src/shared/content.repository.d1.test.ts +147 -1
  150. package/src/shared/content.repository.d1.ts +535 -66
  151. package/src/shared/d1-activity-log.repository.test.ts +4 -0
  152. package/src/shared/d1-activity-log.repository.ts +4 -0
  153. package/src/shared/d1-activity-logger.test.ts +4 -0
  154. package/src/shared/d1-activity-logger.ts +4 -0
  155. package/src/shared/d1-analytics.repository.test.ts +4 -0
  156. package/src/shared/d1-analytics.repository.ts +4 -0
  157. package/src/shared/d1-content-scan.repository.test.ts +9 -5
  158. package/src/shared/d1-content-scan.repository.ts +15 -7
  159. package/src/shared/d1-notification.repository.test.ts +4 -0
  160. package/src/shared/d1-notification.repository.ts +4 -0
  161. package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
  162. package/src/shared/d1-password-reset-token.repository.ts +4 -0
  163. package/src/shared/d1-search.repository.test.ts +4 -0
  164. package/src/shared/d1-search.repository.ts +4 -0
  165. package/src/shared/d1-session.repository.test.ts +4 -0
  166. package/src/shared/d1-session.repository.ts +4 -0
  167. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  168. package/src/shared/d1-user.repository.test.ts +8 -4
  169. package/src/shared/d1-user.repository.ts +15 -5
  170. package/src/shared/d1-widget.repository.test.ts +4 -0
  171. package/src/shared/d1-widget.repository.ts +4 -0
  172. package/src/shared/demo-data-sql.ts +54 -0
  173. package/src/shared/demo-data.repository.d1.ts +20 -0
  174. package/src/shared/execution-context-scheduler.ts +4 -0
  175. package/src/shared/fixed-clock.ts +4 -0
  176. package/src/shared/fts-sync.ts +4 -0
  177. package/src/shared/idempotency.repository.d1.test.ts +4 -0
  178. package/src/shared/idempotency.repository.d1.ts +4 -0
  179. package/src/shared/in-memory-activity-logger.ts +4 -0
  180. package/src/shared/in-memory-notification-service.ts +4 -0
  181. package/src/shared/in-memory-seed.repository.ts +51 -0
  182. package/src/shared/media.repository.d1.test.ts +4 -0
  183. package/src/shared/media.repository.d1.ts +4 -0
  184. package/src/shared/qstash-notification-service.test.ts +67 -0
  185. package/src/shared/qstash-notification-service.ts +55 -0
  186. package/src/shared/query-utils.ts +4 -0
  187. package/src/shared/request-utils.ts +4 -0
  188. package/src/shared/schema-mutator.d1.ts +64 -0
  189. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  190. package/src/shared/seed-layout.repository.d1.ts +62 -0
  191. package/src/shared/seed-registry-cache.test.ts +68 -0
  192. package/src/shared/seed-registry-cache.ts +49 -0
  193. package/src/shared/seed.repository.d1.test.ts +143 -0
  194. package/src/shared/seed.repository.d1.ts +98 -0
  195. package/src/shared/sequential-id-generator.ts +11 -0
  196. package/src/shared/site-settings.repository.d1.ts +53 -0
  197. package/src/shared/storage/factory.ts +16 -15
  198. package/src/shared/storage/s3-bucket.ts +34 -2
  199. package/src/shared/storage-utils.ts +4 -0
  200. package/src/shared/system-stats.repository.d1.test.ts +4 -0
  201. package/src/shared/system-stats.repository.d1.ts +5 -1
  202. package/src/types.ts +23 -3
  203. package/src/upload.ts +134 -123
  204. package/src/widget.ts +6 -2
  205. package/assets/dashboard/assets/index-BKWnlvnV.css +0 -1
  206. package/assets/dashboard/assets/index-BMkd1Irh.js +0 -629
  207. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -0,0 +1,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
+ /** 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
+ })