@beechcms/api 0.5.0 → 0.6.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,170 +1,195 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Context, Hono } from 'hono'
3
- import {
4
- validateAndSanitizeSeedPayload,
5
- resolvePolicies
6
- } from '@beechcms/core'
7
- import { publicProblem } from '../../public/problem-details'
8
- import { cleanStr } from '../../shared/query-utils'
9
- import { applyVisibility } from '../../shared/apply-policies'
10
- import { AppEnv } from '../../types'
11
- import { CONTENT_ERRORS } from '../content/constants'
12
- import { draftGuard } from './draft.middleware'
13
-
14
- const draftApp = new Hono<AppEnv>()
15
-
16
-
17
- function normalizeBody(raw: unknown): Record<string, unknown> {
18
- return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
19
- }
20
-
21
- function logDraftActivity(
22
- context: Context<AppEnv>,
23
- id: string,
24
- slug: string,
25
- title: string,
26
- note: 'draft saved' | 'draft published'
27
- ) {
28
- const actor = context.get('jwtPayload')
29
- context.get('activityLogger').log({
30
- action: 'update',
31
- entityType: 'content',
32
- entityId: id,
33
- entitySlug: slug,
34
- details: { title, note },
35
- actor: {
36
- id: actor.sub,
37
- email: actor.email ?? 'unknown',
38
- name: actor.name ?? null,
39
- },
40
- })
41
- }
42
-
43
- // PUT /:slug/:id/draft — Creates or overwrites the pending draft
44
- draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
45
- const slug = context.req.param('slug')
46
- const id = context.req.param('id')
47
- const seed = context.get('getSeed')(slug)!
48
-
49
- let body: Record<string, unknown>
50
- try {
51
- body = normalizeBody(await context.req.json<unknown>())
52
- } catch {
53
- return publicProblem(context, {
54
- type: 'content-invalid-json',
55
- title: 'Bad Request',
56
- status: 400,
57
- detail: CONTENT_ERRORS.INVALID_JSON_BODY
58
- })
59
- }
60
-
61
- const sensitiveAliases = Object.keys(body).filter((alias) => {
62
- const branch = seed.branches.find((b) => b.alias === alias)
63
- return branch != null && resolvePolicies(branch).privacy !== 'plain'
64
- })
65
-
66
- if (sensitiveAliases.length > 0) {
67
- return publicProblem(context, {
68
- type: 'content-sensitive-field-edit',
69
- title: 'Unprocessable Entity',
70
- status: 422,
71
- detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
72
- })
73
- }
74
-
75
- const validation = validateAndSanitizeSeedPayload(seed, body, {
76
- operation: 'update',
77
- allowNull: true,
78
- requireAtLeastOneValidField: true,
79
- enforceRequiredFields: false,
80
- })
81
-
82
- if (validation.dangerousFields.length > 0) {
83
- return publicProblem(context, {
84
- type: 'content-dangerous-content',
85
- title: 'Unprocessable Entity',
86
- status: 422,
87
- detail: `Dangerous markup in field '${validation.dangerousFields[0]}'`
88
- })
89
- }
90
-
91
- if (validation.details.length > 0) {
92
- return publicProblem(context, {
93
- type: 'content-validation-failed',
94
- title: 'Bad Request',
95
- status: 400,
96
- detail: 'Validation failed',
97
- errors: validation.details
98
- })
99
- }
100
-
101
- const repository = context.get('repository')
102
- await repository.saveDraft(seed, id, validation.data)
103
-
104
- const displayTitle = cleanStr(validation.data[seed.displayNameAlias]) ?? id
105
- logDraftActivity(context, id, slug, displayTitle, 'draft saved')
106
-
107
- return context.json({ success: true })
108
- })
109
-
110
- // GET /:slug/:id/draft — Retrieves the pending draft
111
- draftApp.get('/:slug/:id/draft', draftGuard, async (context) => {
112
- const slug = context.req.param('slug')
113
- const id = context.req.param('id')
114
- const seed = context.get('getSeed')(slug)!
115
-
116
- const repository = context.get('repository')
117
- const draft = await repository.getDraft(seed, id)
118
-
119
- if (!draft) {
120
- return publicProblem(context, {
121
- type: 'draft-not-found',
122
- title: 'Not Found',
123
- status: 404,
124
- detail: 'No pending draft for this entry'
125
- })
126
- }
127
-
128
- return context.json({ data: applyVisibility(draft, seed) })
129
- })
130
-
131
- // POST /:slug/:id/draft/publish — Atomically promotes draft to live
132
- draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
133
- const slug = context.req.param('slug')
134
- const id = context.req.param('id')
135
- const seed = context.get('getSeed')(slug)!
136
-
137
- const repository = context.get('repository')
138
- const draft = await repository.getDraft(seed, id)
139
-
140
- if (!draft) {
141
- return publicProblem(context, {
142
- type: 'draft-not-found',
143
- title: 'Not Found',
144
- status: 404,
145
- detail: 'No pending draft to publish'
146
- })
147
- }
148
-
149
- await repository.publishDraft(seed, id)
150
-
151
- const displayValue = draft[seed.displayNameAlias]
152
- const displayStr = typeof displayValue === 'string' ? displayValue : id
153
- logDraftActivity(context, id, slug, displayStr, 'draft published')
154
-
155
- return context.json({ success: true })
156
- })
157
-
158
- // DELETE /:slug/:id/draft Discards the pending draft
159
- draftApp.delete('/:slug/:id/draft', draftGuard, async (context) => {
160
- const slug = context.req.param('slug')
161
- const id = context.req.param('id')
162
- const seed = context.get('getSeed')(slug)!
163
-
164
- const repository = context.get('repository')
165
- await repository.deleteDraft(seed, id)
166
-
167
- return context.json({ success: true })
168
- })
169
-
170
- export { draftApp }
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 { Context, Hono } from 'hono'
7
+ import {
8
+ validateAndSanitizeSeedPayload,
9
+ resolvePolicies,
10
+ RelationTargetNotFoundError,
11
+ } from '@beechcms/core'
12
+ import { publicProblem } from '../../public/problem-details'
13
+ import { cleanStr } from '../../shared/query-utils'
14
+ import { applyVisibility } from '../../shared/apply-policies'
15
+ import { AppEnv } from '../../types'
16
+ import { CONTENT_ERRORS } from '../content/constants'
17
+ import { draftGuard } from './draft.middleware'
18
+
19
+ const draftApp = new Hono<AppEnv>()
20
+
21
+ // GET /drafts — Unified list of pending drafts across all draft-enabled seeds.
22
+ draftApp.get('/drafts', async (context) => {
23
+ const seeds = context.get('seedRegistry').draftEnabled()
24
+ const repository = context.get('repository')
25
+ const drafts = await repository.findPendingDrafts(seeds)
26
+ return context.json(drafts)
27
+ })
28
+
29
+ function normalizeBody(raw: unknown): Record<string, unknown> {
30
+ return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
31
+ }
32
+
33
+ function logDraftActivity(
34
+ context: Context<AppEnv>,
35
+ id: string,
36
+ slug: string,
37
+ title: string,
38
+ note: 'draft saved' | 'draft published'
39
+ ) {
40
+ const actor = context.get('jwtPayload')
41
+ context.get('activityLogger').log({
42
+ action: 'update',
43
+ entityType: 'content',
44
+ entityId: id,
45
+ entitySlug: slug,
46
+ details: { title, note },
47
+ actor: {
48
+ id: actor.sub,
49
+ email: actor.email ?? 'unknown',
50
+ name: [actor.name, actor.surname].filter(Boolean).join(' ') || null,
51
+ },
52
+ })
53
+ }
54
+
55
+ // PUT /:slug/:id/draft — Creates or overwrites the pending draft
56
+ draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
57
+ const slug = context.req.param('slug')
58
+ const id = context.req.param('id')
59
+ const seed = context.get('getSeed')(slug)!
60
+
61
+ let body: Record<string, unknown>
62
+ try {
63
+ body = normalizeBody(await context.req.json<unknown>())
64
+ } catch {
65
+ return publicProblem(context, {
66
+ type: 'content-invalid-json',
67
+ title: 'Bad Request',
68
+ status: 400,
69
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
70
+ })
71
+ }
72
+
73
+ const sensitiveAliases = Object.keys(body).filter((alias) => {
74
+ const branch = seed.branches.find((b) => b.alias === alias)
75
+ return branch != null && resolvePolicies(branch).privacy !== 'plain'
76
+ })
77
+
78
+ if (sensitiveAliases.length > 0) {
79
+ return publicProblem(context, {
80
+ type: 'content-sensitive-field-edit',
81
+ title: 'Unprocessable Entity',
82
+ status: 422,
83
+ detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
84
+ })
85
+ }
86
+
87
+ const validation = validateAndSanitizeSeedPayload(seed, body, {
88
+ operation: 'update',
89
+ allowNull: true,
90
+ requireAtLeastOneValidField: true,
91
+ enforceRequiredFields: false,
92
+ idGenerator: context.get('idGenerator'),
93
+ })
94
+
95
+ if (validation.dangerousFields.length > 0) {
96
+ return publicProblem(context, {
97
+ type: 'content-dangerous-content',
98
+ title: 'Unprocessable Entity',
99
+ status: 422,
100
+ detail: `Dangerous markup in field '${validation.dangerousFields[0]}'`
101
+ })
102
+ }
103
+
104
+ if (validation.details.length > 0) {
105
+ return publicProblem(context, {
106
+ type: 'content-validation-failed',
107
+ title: 'Bad Request',
108
+ status: 400,
109
+ detail: 'Validation failed',
110
+ errors: validation.details
111
+ })
112
+ }
113
+
114
+ const repository = context.get('repository')
115
+ await repository.saveDraft(seed, id, validation.data)
116
+
117
+ const displayTitle = cleanStr(validation.data[seed.displayNameAlias]) ?? id
118
+ logDraftActivity(context, id, slug, displayTitle, 'draft saved')
119
+
120
+ return context.json({ success: true })
121
+ })
122
+
123
+ // GET /:slug/:id/draft — Retrieves the pending draft
124
+ draftApp.get('/:slug/:id/draft', draftGuard, async (context) => {
125
+ const slug = context.req.param('slug')
126
+ const id = context.req.param('id')
127
+ const seed = context.get('getSeed')(slug)!
128
+
129
+ const repository = context.get('repository')
130
+ const draft = await repository.getDraft(seed, id)
131
+
132
+ if (!draft) {
133
+ return publicProblem(context, {
134
+ type: 'draft-not-found',
135
+ title: 'Not Found',
136
+ status: 404,
137
+ detail: 'No pending draft for this entry'
138
+ })
139
+ }
140
+
141
+ return context.json({ data: applyVisibility(draft, seed) })
142
+ })
143
+
144
+ // POST /:slug/:id/draft/publish — Atomically promotes draft to live
145
+ draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
146
+ const slug = context.req.param('slug')
147
+ const id = context.req.param('id')
148
+ const seed = context.get('getSeed')(slug)!
149
+
150
+ const repository = context.get('repository')
151
+ const draft = await repository.getDraft(seed, id)
152
+
153
+ if (!draft) {
154
+ return publicProblem(context, {
155
+ type: 'draft-not-found',
156
+ title: 'Not Found',
157
+ status: 404,
158
+ detail: 'No pending draft to publish'
159
+ })
160
+ }
161
+
162
+ try {
163
+ await repository.publishDraft(seed, id)
164
+ } catch (err) {
165
+ if (err instanceof RelationTargetNotFoundError) {
166
+ return publicProblem(context, {
167
+ type: 'relation-target-not-found',
168
+ title: 'Relation Target Not Found',
169
+ status: 422,
170
+ detail: `Field '${err.alias}' references '${err.targetSeed}' id='${err.value}' which does not exist`,
171
+ })
172
+ }
173
+ throw err
174
+ }
175
+
176
+ const displayValue = draft[seed.displayNameAlias]
177
+ const displayStr = typeof displayValue === 'string' ? displayValue : id
178
+ logDraftActivity(context, id, slug, displayStr, 'draft published')
179
+
180
+ return context.json({ success: true })
181
+ })
182
+
183
+ // DELETE /:slug/:id/draft — Discards the pending draft
184
+ draftApp.delete('/:slug/:id/draft', draftGuard, async (context) => {
185
+ const slug = context.req.param('slug')
186
+ const id = context.req.param('id')
187
+ const seed = context.get('getSeed')(slug)!
188
+
189
+ const repository = context.get('repository')
190
+ await repository.deleteDraft(seed, id)
191
+
192
+ return context.json({ success: true })
193
+ })
194
+
195
+ export { draftApp }
@@ -1,62 +1,66 @@
1
- import { createMiddleware } from 'hono/factory'
2
- import { EntryNotFoundError } from '@beechcms/core'
3
- import { publicProblem } from '../../public/problem-details'
4
- import { CONTENT_ERRORS } from '../content/constants'
5
- import type { AppEnv } from '../../types'
6
-
7
- /**
8
- * Middleware that guards draft-related endpoints.
9
- * Responsibilities:
10
- * 1. Validates that the targeted Seed schema exists.
11
- * 2. Ensures the Seed configuration explicitly allows drafts.
12
- * 3. Confirms that the main live content entry exists in the repository.
13
- */
14
- export const draftGuard = createMiddleware<AppEnv>(async (context, next) => {
15
- const slug = context.req.param('slug')
16
- const id = context.req.param('id')
17
-
18
- if (!slug || !id) {
19
- return publicProblem(context, {
20
- type: 'content-invalid-request',
21
- title: 'Bad Request',
22
- status: 400,
23
- detail: 'Missing required route parameters',
24
- })
25
- }
26
-
27
- const seed = context.get('getSeed')(slug)
28
- if (!seed) {
29
- return publicProblem(context, {
30
- type: 'content-seed-not-found',
31
- title: 'Not Found',
32
- status: 404,
33
- detail: CONTENT_ERRORS.SEED_NOT_FOUND,
34
- })
35
- }
36
-
37
- if (!seed.allowDrafts) {
38
- return publicProblem(context, {
39
- type: 'draft-not-allowed',
40
- title: 'Method Not Allowed',
41
- status: 405,
42
- detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
43
- })
44
- }
45
-
46
- const repository = context.get('repository')
47
- try {
48
- await repository.findById(seed, id)
49
- } catch (error) {
50
- if (error instanceof EntryNotFoundError) {
51
- return publicProblem(context, {
52
- type: 'content-not-found',
53
- title: 'Not Found',
54
- status: 404,
55
- detail: CONTENT_ERRORS.NOT_FOUND,
56
- })
57
- }
58
- throw error
59
- }
60
-
61
- await next()
62
- })
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
+ import { createMiddleware } from 'hono/factory'
6
+ import { EntryNotFoundError } from '@beechcms/core'
7
+ import { publicProblem } from '../../public/problem-details'
8
+ import { CONTENT_ERRORS } from '../content/constants'
9
+ import type { AppEnv } from '../../types'
10
+
11
+ /**
12
+ * Middleware that guards draft-related endpoints.
13
+ * Responsibilities:
14
+ * 1. Validates that the targeted Seed schema exists.
15
+ * 2. Ensures the Seed configuration explicitly allows drafts.
16
+ * 3. Confirms that the main live content entry exists in the repository.
17
+ */
18
+ export const draftGuard = createMiddleware<AppEnv>(async (context, next) => {
19
+ const slug = context.req.param('slug')
20
+ const id = context.req.param('id')
21
+
22
+ if (!slug || !id) {
23
+ return publicProblem(context, {
24
+ type: 'content-invalid-request',
25
+ title: 'Bad Request',
26
+ status: 400,
27
+ detail: 'Missing required route parameters',
28
+ })
29
+ }
30
+
31
+ const seed = context.get('getSeed')(slug)
32
+ if (!seed) {
33
+ return publicProblem(context, {
34
+ type: 'content-seed-not-found',
35
+ title: 'Not Found',
36
+ status: 404,
37
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND,
38
+ })
39
+ }
40
+
41
+ if (!seed.allowDrafts) {
42
+ return publicProblem(context, {
43
+ type: 'draft-not-allowed',
44
+ title: 'Method Not Allowed',
45
+ status: 405,
46
+ detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
47
+ })
48
+ }
49
+
50
+ const repository = context.get('repository')
51
+ try {
52
+ await repository.findById(seed, id)
53
+ } catch (error) {
54
+ if (error instanceof EntryNotFoundError) {
55
+ return publicProblem(context, {
56
+ type: 'content-not-found',
57
+ title: 'Not Found',
58
+ status: 404,
59
+ detail: CONTENT_ERRORS.NOT_FOUND,
60
+ })
61
+ }
62
+ throw error
63
+ }
64
+
65
+ await next()
66
+ })
@@ -1 +1,5 @@
1
- export { draftApp } from './draft.handler'
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
+ export { draftApp } from './draft.handler'
@@ -1,38 +1,42 @@
1
- import type { OutboundEmail } from './email.types'
2
-
3
- /**
4
- * EmailProvider — formal contract for email sending providers.
5
- *
6
- * Every implementation (Resend, SendGrid, Mailgun, SMTP, ...) MUST comply
7
- * with this interface. It is the only point of coupling between the email module and
8
- * any third-party external service.
9
- *
10
- * ─── HOW TO CHANGE PROVIDER ──────────────────────────────────────────────────
11
- * 1. Create a new file under `providers/` (e.g., `providers/sendgrid.ts`).
12
- * 2. Export a class that implements this interface.
13
- * 3. In `email.service.ts`, replace the import and instantiation of the current
14
- * provider with your new class in the `createProvider()` function.
15
- * 4. Update the necessary environment variables in `types.ts` and `wrangler.jsonc`.
16
- * 5. No other file in the project needs to be touched.
17
- * ─────────────────────────────────────────────────────────────────────────────
18
- */
19
- export interface EmailProvider {
20
- /**
21
- * Sends a single transactional email.
22
- *
23
- * @param email - The fully resolved message: sender, recipient,
24
- * subject, and HTML body. Use the builders in `templates/` to
25
- * construct this object correctly.
26
- *
27
- * @returns Promise that resolves when the provider has **accepted** the
28
- * message for delivery. Acceptance does not guarantee delivery
29
- * to the inbox — that depends on the recipient's server and the
30
- * provider's deliverability.
31
- *
32
- * @throws {Error} If the provider rejects the request (failed authentication,
33
- * network error, invalid payload). The caller
34
- * (`email.service.ts`) is responsible for catching and handling
35
- * this error appropriately.
36
- */
37
- send(email: OutboundEmail): Promise<void>
38
- }
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
+ import type { OutboundEmail } from './email.types'
6
+
7
+ /**
8
+ * EmailProvider formal contract for email sending providers.
9
+ *
10
+ * Every implementation (Resend, SendGrid, Mailgun, SMTP, ...) MUST comply
11
+ * with this interface. It is the only point of coupling between the email module and
12
+ * any third-party external service.
13
+ *
14
+ * ─── HOW TO CHANGE PROVIDER ──────────────────────────────────────────────────
15
+ * 1. Create a new file under `providers/` (e.g., `providers/sendgrid.ts`).
16
+ * 2. Export a class that implements this interface.
17
+ * 3. In `email.service.ts`, replace the import and instantiation of the current
18
+ * provider with your new class in the `createProvider()` function.
19
+ * 4. Update the necessary environment variables in `types.ts` and `wrangler.jsonc`.
20
+ * 5. No other file in the project needs to be touched.
21
+ * ─────────────────────────────────────────────────────────────────────────────
22
+ */
23
+ export interface EmailProvider {
24
+ /**
25
+ * Sends a single transactional email.
26
+ *
27
+ * @param email - The fully resolved message: sender, recipient,
28
+ * subject, and HTML body. Use the builders in `templates/` to
29
+ * construct this object correctly.
30
+ *
31
+ * @returns Promise that resolves when the provider has **accepted** the
32
+ * message for delivery. Acceptance does not guarantee delivery
33
+ * to the inbox that depends on the recipient's server and the
34
+ * provider's deliverability.
35
+ *
36
+ * @throws {Error} If the provider rejects the request (failed authentication,
37
+ * network error, invalid payload). The caller
38
+ * (`email.service.ts`) is responsible for catching and handling
39
+ * this error appropriately.
40
+ */
41
+ send(email: OutboundEmail): Promise<void>
42
+ }