@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
@@ -1,5 +1,10 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { Hono } from 'hono'
7
+ import { sha256hex, type SiteSettings } from '@beechcms/core'
3
8
  import type { Env, Variables } from '../../types'
4
9
 
5
10
  const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
@@ -12,24 +17,96 @@ const ACTIVITY_LOG_LIMIT = 30
12
17
 
13
18
  /**
14
19
  * GET /api/settings
15
- * Retrieves the general site configuration.
20
+ * Retrieves the general site configuration from the database.
16
21
  */
17
22
  settingsApp.get('/', async (context) => {
23
+ const s = await context.get('siteSettingsRepository').getAll()
18
24
  return context.json({
19
- siteTitle: 'Beech CMS',
25
+ siteTitle: s.siteTitle,
20
26
  siteLogo: '/beechLogoDark.svg',
21
- defaultLanguage: 'it',
27
+ defaultLanguage: s.defaultLanguage,
28
+ timezone: s.timezone,
29
+ currency: s.currency,
30
+ company: {
31
+ name: s.companyName,
32
+ website: s.companyWebsite,
33
+ abbreviation: s.companyAbbreviation,
34
+ },
22
35
  dateFormat: context.env.DATE_FORMAT || 'DD-MM-YYYY',
23
36
  features: {
24
37
  drafts: true,
25
38
  media: true,
26
39
  search: true,
27
40
  activityLog: true,
28
- email: !!(context.env.EMAIL_API_KEY || context.env.RESEND_API_KEY),
29
- }
41
+ email: context.env.EMAIL_PROVIDER === 'smtp' || !!(context.env.EMAIL_API_KEY || context.env.RESEND_API_KEY),
42
+ },
30
43
  })
31
44
  })
32
45
 
46
+ /**
47
+ * PUT /api/settings
48
+ * Updates the general site configuration in the database.
49
+ */
50
+ settingsApp.put('/', async (context) => {
51
+ let payload: Record<string, unknown>
52
+ try {
53
+ payload = await context.req.json()
54
+ } catch {
55
+ return context.json({ error: 'Invalid JSON body' }, 400)
56
+ }
57
+
58
+ const siteTitle = typeof payload.siteTitle === 'string' ? payload.siteTitle.trim() : undefined
59
+ const defaultLanguage = typeof payload.defaultLanguage === 'string' ? payload.defaultLanguage.trim() : undefined
60
+ const timezone = typeof payload.timezone === 'string' ? payload.timezone.trim() : undefined
61
+ const currency = typeof payload.currency === 'string' ? payload.currency.trim() : undefined
62
+
63
+ let companyName: string | undefined | null = undefined
64
+ let companyWebsite: string | undefined | null = undefined
65
+ let companyAbbreviation: string | undefined | null = undefined
66
+
67
+ if (payload.company !== undefined) {
68
+ if (payload.company === null) {
69
+ companyName = null
70
+ companyWebsite = null
71
+ companyAbbreviation = null
72
+ } else if (typeof payload.company === 'object') {
73
+ const company = payload.company as Record<string, unknown>
74
+ companyName = typeof company.name === 'string' ? company.name.trim() : (company.name === null ? null : undefined)
75
+ companyWebsite = typeof company.website === 'string' ? (company.website.trim() || null) : (company.website === null ? null : undefined)
76
+ companyAbbreviation = typeof company.abbreviation === 'string' ? (company.abbreviation.trim() || null) : (company.abbreviation === null ? null : undefined)
77
+ }
78
+ }
79
+
80
+ if (defaultLanguage !== undefined && !['it', 'en'].includes(defaultLanguage)) {
81
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid default language (must be it or en)' }, 400)
82
+ }
83
+
84
+ if (companyWebsite && companyWebsite !== '') {
85
+ try {
86
+ new URL(companyWebsite)
87
+ } catch {
88
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid company website URL' }, 400)
89
+ }
90
+ }
91
+
92
+ const fieldsToUpdate: Partial<SiteSettings> = {}
93
+ if (siteTitle !== undefined) fieldsToUpdate.siteTitle = siteTitle
94
+ if (defaultLanguage !== undefined) fieldsToUpdate.defaultLanguage = defaultLanguage
95
+ if (timezone !== undefined) fieldsToUpdate.timezone = timezone
96
+ if (currency !== undefined) fieldsToUpdate.currency = currency
97
+ if (companyName !== undefined) fieldsToUpdate.companyName = companyName
98
+ if (companyWebsite !== undefined) fieldsToUpdate.companyWebsite = companyWebsite
99
+ if (companyAbbreviation !== undefined) fieldsToUpdate.companyAbbreviation = companyAbbreviation
100
+
101
+ // If companyName is updated and siteTitle isn't specified, sync siteTitle
102
+ if (companyName && siteTitle === undefined) {
103
+ fieldsToUpdate.siteTitle = companyName
104
+ }
105
+
106
+ await context.get('siteSettingsRepository').setMany(fieldsToUpdate)
107
+ return context.json({ success: true })
108
+ })
109
+
33
110
  /**
34
111
  * GET /api/settings/me
35
112
  * Retrieves the currently authenticated user's profile and preferences.
@@ -49,11 +126,18 @@ settingsApp.get('/me', async (context) => {
49
126
  notificationPreferences = {}
50
127
  }
51
128
 
129
+ let avatarUrl = currentUser.avatarUrl
130
+ if (!avatarUrl && currentUser.email) {
131
+ const emailHash = await sha256hex(currentUser.email.trim().toLowerCase())
132
+ avatarUrl = `https://gravatar.com/avatar/${emailHash}?d=mp`
133
+ }
134
+
52
135
  return context.json({
53
136
  id: currentUser.id,
54
137
  email: currentUser.email,
55
138
  name: currentUser.name,
56
- avatarUrl: currentUser.avatarUrl,
139
+ surname: currentUser.surname,
140
+ avatarUrl,
57
141
  notificationPrefs: {
58
142
  contentCreate: notificationPreferences.contentCreate ?? true,
59
143
  contentUpdate: notificationPreferences.contentUpdate ?? true,
@@ -78,6 +162,7 @@ settingsApp.put('/profile', async (context) => {
78
162
  }
79
163
 
80
164
  const nameInput = typeof payload.name === 'string' ? payload.name.trim() : null
165
+ const surnameInput = typeof payload.surname === 'string' ? payload.surname.trim() : null
81
166
  const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
82
167
 
83
168
  if (emailInput !== null && !EMAIL_VALIDATION_REGEX.test(emailInput)) {
@@ -88,7 +173,11 @@ settingsApp.put('/profile', async (context) => {
88
173
  return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Name is too long (maximum 100 characters)' }, 400)
89
174
  }
90
175
 
91
- const hasNoFields = nameInput === null && emailInput === null
176
+ if (surnameInput !== null && surnameInput.length > 100) {
177
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Surname is too long (maximum 100 characters)' }, 400)
178
+ }
179
+
180
+ const hasNoFields = nameInput === null && surnameInput === null && emailInput === null
92
181
  if (hasNoFields) {
93
182
  return context.json({ error: 'No fields to update' }, 400)
94
183
  }
@@ -100,8 +189,9 @@ settingsApp.put('/profile', async (context) => {
100
189
  }
101
190
  }
102
191
 
103
- const fieldsToUpdate: { name?: string; email?: string } = {}
192
+ const fieldsToUpdate: { name?: string; surname?: string; email?: string } = {}
104
193
  if (nameInput !== null) fieldsToUpdate.name = nameInput
194
+ if (surnameInput !== null) fieldsToUpdate.surname = surnameInput
105
195
  if (emailInput !== null) fieldsToUpdate.email = emailInput
106
196
 
107
197
  await context.get('userRepository').updateProfile(userId, fieldsToUpdate)
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { Hono } from 'hono'
3
7
  import type { Env, Variables } from '../../types'
@@ -7,16 +11,25 @@ const setupApp = new Hono<{ Bindings: Env; Variables: Variables }>()
7
11
 
8
12
  /**
9
13
  * GET /auth/setup
10
- * Checks if the application needs an initial setup (i.e., if no users exist).
14
+ * Returns setup status + environment flags for the wizard.
11
15
  */
12
16
  setupApp.get('/auth/setup', async (context) => {
13
17
  const userCount = await context.get('userRepository').countAll()
14
- return context.json({ needsSetup: userCount === 0 })
18
+ const isDeveloper = context.env.ENV === 'development'
19
+ const mail =
20
+ context.env.EMAIL_PROVIDER === 'smtp' ||
21
+ !!(context.env.EMAIL_API_KEY || context.env.RESEND_API_KEY)
22
+ const qstash = !!context.env.QSTASH_TOKEN
23
+
24
+ return context.json({
25
+ needsSetup: userCount === 0,
26
+ environment: { isDeveloper, services: { mail, qstash } },
27
+ })
15
28
  })
16
29
 
17
30
  /**
18
31
  * POST /auth/setup
19
- * Creates the first administrator account. This endpoint is disabled once at least one user exists.
32
+ * Creates the first administrator account and persists site defaults.
20
33
  */
21
34
  setupApp.post('/auth/setup', async (context) => {
22
35
  const userCount = await context.get('userRepository').countAll()
@@ -26,7 +39,7 @@ setupApp.post('/auth/setup', async (context) => {
26
39
  type: 'setup-already-done',
27
40
  title: 'Setup already completed',
28
41
  status: 403,
29
- detail: 'An administrator account already exists. Initial setup can only be performed once.'
42
+ detail: 'An administrator account already exists. Initial setup can only be performed once.',
30
43
  })
31
44
  }
32
45
 
@@ -38,7 +51,7 @@ setupApp.post('/auth/setup', async (context) => {
38
51
  type: 'bad-request',
39
52
  title: 'Invalid JSON body',
40
53
  status: 400,
41
- detail: 'The request body could not be parsed as valid JSON.'
54
+ detail: 'The request body could not be parsed as valid JSON.',
42
55
  })
43
56
  }
44
57
 
@@ -47,18 +60,19 @@ setupApp.post('/auth/setup', async (context) => {
47
60
  type: 'bad-request',
48
61
  title: 'Invalid request',
49
62
  status: 400,
50
- detail: 'The request payload is missing or invalid.'
63
+ detail: 'The request payload is missing or invalid.',
51
64
  })
52
65
  }
53
66
 
54
- const { email, password, name } = payload as Record<string, unknown>
67
+ const p = payload as Record<string, unknown>
68
+ const { email, password, name, surname, settings, track, company, loadDemoData } = p
55
69
 
56
70
  if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
57
71
  return publicProblem(context, {
58
72
  type: 'validation-error',
59
73
  title: 'Valid email required',
60
74
  status: 422,
61
- detail: 'A valid email address is required for the administrator account.'
75
+ detail: 'A valid email address is required for the administrator account.',
62
76
  })
63
77
  }
64
78
 
@@ -67,13 +81,97 @@ setupApp.post('/auth/setup', async (context) => {
67
81
  type: 'validation-error',
68
82
  title: 'Invalid password',
69
83
  status: 422,
70
- detail: 'Password must be between 8 and 128 characters long.'
84
+ detail: 'Password must be between 8 and 128 characters long.',
85
+ })
86
+ }
87
+
88
+ if (!settings || typeof settings !== 'object') {
89
+ return publicProblem(context, {
90
+ type: 'validation-error',
91
+ title: 'Settings required',
92
+ status: 422,
93
+ detail: 'settings.language, settings.timezone, and settings.currency are required.',
71
94
  })
72
95
  }
73
96
 
97
+ const s = settings as Record<string, unknown>
98
+ const language = typeof s.language === 'string' ? s.language : ''
99
+ const timezone = typeof s.timezone === 'string' ? s.timezone.trim() : ''
100
+ const currency = typeof s.currency === 'string' ? s.currency.trim() : ''
101
+
102
+ if (!['it', 'en'].includes(language)) {
103
+ return publicProblem(context, {
104
+ type: 'validation-error',
105
+ title: 'Invalid language',
106
+ status: 422,
107
+ detail: 'settings.language must be "it" or "en".',
108
+ })
109
+ }
110
+
111
+ if (!timezone) {
112
+ return publicProblem(context, {
113
+ type: 'validation-error',
114
+ title: 'Timezone required',
115
+ status: 422,
116
+ detail: 'settings.timezone must be a non-empty IANA timezone string.',
117
+ })
118
+ }
119
+
120
+ if (!currency) {
121
+ return publicProblem(context, {
122
+ type: 'validation-error',
123
+ title: 'Currency required',
124
+ status: 422,
125
+ detail: 'settings.currency must be a non-empty ISO 4217 currency code.',
126
+ })
127
+ }
128
+
129
+ if (track === 'normal') {
130
+ if (!company || typeof company !== 'object') {
131
+ return publicProblem(context, {
132
+ type: 'validation-error',
133
+ title: 'Company info required',
134
+ status: 422,
135
+ detail: 'company.name and company.website are required for the normal track.',
136
+ })
137
+ }
138
+ const c = company as Record<string, unknown>
139
+ if (typeof c.name !== 'string' || !c.name.trim()) {
140
+ return publicProblem(context, {
141
+ type: 'validation-error',
142
+ title: 'Company name required',
143
+ status: 422,
144
+ detail: 'company.name must be a non-empty string.',
145
+ })
146
+ }
147
+ if (typeof c.website !== 'string' || !c.website.trim()) {
148
+ return publicProblem(context, {
149
+ type: 'validation-error',
150
+ title: 'Company website required',
151
+ status: 422,
152
+ detail: 'company.website must be a valid URL.',
153
+ })
154
+ }
155
+ try {
156
+ new URL(c.website.trim())
157
+ } catch {
158
+ return publicProblem(context, {
159
+ type: 'validation-error',
160
+ title: 'Invalid company website',
161
+ status: 422,
162
+ detail: 'company.website must be a valid URL.',
163
+ })
164
+ }
165
+ }
166
+
167
+ if (track === 'developer' && loadDemoData === true) {
168
+ await context.get('demoDataRepository').loadDemoData()
169
+ }
170
+
74
171
  const passwordHash = await context.get('hashProvider').hash(password)
75
172
  const normalizedEmail = email.trim().toLowerCase()
76
173
  const normalizedName = typeof name === 'string' ? name.trim() : null
174
+ const normalizedSurname = typeof surname === 'string' ? surname.trim() : null
77
175
 
78
176
  await context.get('userRepository').create({
79
177
  id: context.get('idGenerator').uuid(),
@@ -81,11 +179,26 @@ setupApp.post('/auth/setup', async (context) => {
81
179
  passwordHash,
82
180
  role: 'admin',
83
181
  name: normalizedName,
182
+ surname: normalizedSurname,
84
183
  })
85
184
 
185
+ if (track === 'normal' && company && typeof company === 'object') {
186
+ const c = company as Record<string, unknown>
187
+ const companyName = (c.name as string).trim()
188
+ await context.get('siteSettingsRepository').setMany({
189
+ defaultLanguage: language,
190
+ timezone,
191
+ currency,
192
+ companyName,
193
+ companyWebsite: (c.website as string).trim(),
194
+ companyAbbreviation: typeof c.abbreviation === 'string' ? c.abbreviation.trim() || null : null,
195
+ siteTitle: companyName,
196
+ })
197
+ } else {
198
+ await context.get('siteSettingsRepository').setMany({ defaultLanguage: language, timezone, currency })
199
+ }
200
+
86
201
  return context.json({ success: true }, 201)
87
202
  })
88
203
 
89
204
  export { setupApp }
90
-
91
-
@@ -1 +1,5 @@
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
+
1
5
  export { statsApp } from './stats.handler'
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { Hono } from 'hono'
3
7
  import { SystemClock } from '@beechcms/core'
@@ -118,46 +122,32 @@ statsApp.get('/stats/unused-media', async (context) => {
118
122
  */
119
123
  statsApp.get('/stats/setup-checklist', async (context) => {
120
124
  try {
121
- const { DB } = context.env
125
+ const setupChecklistRepository = context.get('setupChecklistRepository')
122
126
  const seeds = context.get('seedRegistry').all()
123
127
 
124
- // 1. System tables present
128
+ const existingTables = await setupChecklistRepository.getExistingTableNames()
129
+
125
130
  const systemTableNames = [
126
131
  'users', 'refresh_tokens', 'media_objects',
127
132
  'analytics', 'system_stats', 'activity_logs',
128
133
  ]
129
- const tablesResult = await DB.prepare(
130
- `SELECT name FROM sqlite_master WHERE type='table'`
131
- ).all<{ name: string }>()
132
- const existingTables = new Set((tablesResult.results ?? []).map(row => row.name))
133
- const systemTablesOk = systemTableNames.every(tableName => existingTables.has(tableName))
134
+ const systemTablesOk = systemTableNames.every(name => existingTables.has(name))
134
135
 
135
- // 2. Seeds defined
136
136
  const seedsCount = seeds.length
137
-
138
- // 3. Content tables created (seed:load was run)
139
137
  const contentTablesOk = seedsCount > 0 && seeds.every(seed => existingTables.has(`content_${seed.slug}`))
140
138
 
141
- // 4. Admin account exists
142
139
  let adminExists = false
143
140
  try {
144
- const adminCountResult = await DB.prepare(
145
- `SELECT COUNT(*) as count FROM users WHERE role = 'admin'`
146
- ).first<{ count: number }>()
147
- adminExists = (adminCountResult?.count ?? 0) > 0
141
+ adminExists = (await setupChecklistRepository.countAdmins()) > 0
148
142
  } catch {
149
- // table may not exist yet
143
+ // users table may not exist yet
150
144
  }
151
145
 
152
- // 5. At least one content entry in the first seed's table
153
146
  let hasContent = false
154
147
  const firstSeedSlug = seeds[0]?.slug ?? null
155
148
  if (firstSeedSlug && existingTables.has(`content_${firstSeedSlug}`)) {
156
149
  try {
157
- const contentCountResult = await DB.prepare(
158
- `SELECT COUNT(*) as count FROM content_${firstSeedSlug}`
159
- ).first<{ count: number }>()
160
- hasContent = (contentCountResult?.count ?? 0) > 0
150
+ hasContent = (await setupChecklistRepository.countEntriesInSeed(firstSeedSlug)) > 0
161
151
  } catch {
162
152
  // ignore
163
153
  }
@@ -0,0 +1,63 @@
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 { Hono } from 'hono'
6
+ import { Receiver } from '@upstash/qstash'
7
+ import type { AppEnv } from '../../types'
8
+ import type { CreateNotificationInput } from '@beechcms/core'
9
+
10
+ export const webhooksApp = new Hono<AppEnv>()
11
+
12
+ webhooksApp.post('/qstash', async (context) => {
13
+ const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY
14
+ const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY
15
+
16
+ if (!currentSigningKey || !nextSigningKey) {
17
+ console.error('QStash webhook called but signing keys are not configured')
18
+ return context.text('Configuration error', 500)
19
+ }
20
+
21
+ const receiver = new Receiver({
22
+ currentSigningKey,
23
+ nextSigningKey,
24
+ })
25
+
26
+ const body = await context.req.text()
27
+ const signature = context.req.header('Upstash-Signature')
28
+
29
+ if (!signature) {
30
+ return context.text('Missing signature', 401)
31
+ }
32
+
33
+ try {
34
+ const isValid = await receiver.verify({
35
+ signature,
36
+ body,
37
+ })
38
+
39
+ if (!isValid) {
40
+ return context.text('Invalid signature', 401)
41
+ }
42
+ } catch (err) {
43
+ console.error('QStash signature verification failed', err)
44
+ return context.text('Invalid signature', 401)
45
+ }
46
+
47
+ try {
48
+ const input = JSON.parse(body) as CreateNotificationInput
49
+
50
+ const notificationRepository = context.get('notificationRepository')
51
+
52
+ await notificationRepository.create({
53
+ title: input.title,
54
+ message: input.message,
55
+ type: input.type ?? 'info',
56
+ })
57
+
58
+ return context.text('OK', 200)
59
+ } catch (error) {
60
+ console.error('Failed to process QStash webhook payload', error)
61
+ return context.text('Internal server error', 500)
62
+ }
63
+ })
package/src/index.ts CHANGED
@@ -1,31 +1,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
+
1
5
  import { createBeechApp } from './factory'
2
6
  import { SeedRegistry, SystemIdGenerator } from '@beechcms/core'
3
7
  import { runCronAutomations } from './features/automations'
4
8
  import { D1AutomationRepository } from './shared/automations.repository.d1'
5
9
  import { D1ContentRepository } from './shared/content.repository.d1'
10
+ import { D1SeedRepository } from './shared/seed.repository.d1'
6
11
  import type { Env } from './types'
7
12
 
8
- let seeds: any[] = []
9
-
10
- try {
11
- // @ts-ignore
12
- const mod = await import('../seed.ts')
13
- const registry = mod.default || mod.SEED_REGISTRY || mod
14
- seeds = (typeof registry === 'object' && !Array.isArray(registry))
15
- ? Object.values(registry)
16
- : registry
17
- } catch (e) {
18
- // Fallback se seed.ts non esiste
19
- }
20
-
21
- const app = createBeechApp({ seeds })
13
+ const app = createBeechApp({ seeds: [] })
22
14
 
23
- app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
24
-
25
- const validSeeds = seeds.filter((s: any) => s && typeof s === 'object' && 'slug' in s)
15
+ app.get('/', (c) => c.text('Beech API is running'))
26
16
 
27
17
  export default {
28
- fetch: app.fetch,
18
+ fetch(request: Request, env: Env, ctx: ExecutionContext) {
19
+ if (env.ENV === 'development') {
20
+ const checks: Array<{ name: string; url: string }> = [
21
+ { name: 'MinIO', url: (env.R2_ENDPOINT ?? 'http://localhost:9000') + '/minio/health/live' },
22
+ { name: 'Mailpit', url: `http://${env.SMTP_HOST ?? 'localhost'}:${env.SMTP_PORT ?? '8025'}/livez` },
23
+ ]
24
+ for (const c of checks) {
25
+ fetch(c.url).catch(() => {
26
+ console.warn(
27
+ `\n⚠️ ${c.name} non raggiungibile su ${c.url}\n` +
28
+ ` Beech in dev richiede lo stack Docker completo.\n` +
29
+ ` Avvialo con: npm run dev:full\n`
30
+ )
31
+ })
32
+ }
33
+ }
34
+ return app.fetch(request, env, ctx)
35
+ },
29
36
 
30
37
  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
31
38
  const scheduledTime = controller?.scheduledTime ?? Date.now()
@@ -37,7 +44,9 @@ export default {
37
44
 
38
45
  const automationRepository = new D1AutomationRepository(env.DB)
39
46
  const contentRepository = new D1ContentRepository(env.DB)
40
- const registry = new SeedRegistry(validSeeds)
47
+ const seedRepository = new D1SeedRepository(env.DB)
48
+ const seeds = await seedRepository.listActive()
49
+ const registry = new SeedRegistry(seeds)
41
50
  const getSeed = (slug: string) => registry.get(slug) ?? null
42
51
 
43
52
  ctx.waitUntil(
@@ -1,3 +1,7 @@
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
+
1
5
  /**
2
6
  * Media utils: estrazione chiavi R2 dal data di un'entry.
3
7
  * Usato alla cancellazione entry per eliminare i file associati da R2.
@@ -1,3 +1,7 @@
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
+
1
5
  import { createMiddleware } from 'hono/factory'
2
6
  import type { IHashProvider, ITokenService, IClock } from '@beechcms/core'
3
7
  import { SystemClock } from '@beechcms/core'
@@ -11,8 +15,17 @@ export interface AuthProviderOverrides {
11
15
  clock?: IClock
12
16
  }
13
17
 
18
+ // Valore di default presente in .dev.vars.example: mai accettabile in produzione.
19
+ const DEV_JWT_SECRET = 'sviluppo-secret-cambiami-almeno-32-byte-per-sicurezza-hono'
20
+
14
21
  export const authProvidersMiddleware = (overrides?: AuthProviderOverrides) => {
15
22
  return createMiddleware<AppEnv>(async (context, next) => {
23
+ if (context.env.ENV === 'production' && context.env.JWT_SECRET === DEV_JWT_SECRET) {
24
+ throw new Error(
25
+ 'JWT_SECRET non configurato: in produzione impostare un segreto univoco con `wrangler secret put JWT_SECRET`',
26
+ )
27
+ }
28
+
16
29
  const resolvedClock = overrides?.clock ?? SystemClock
17
30
  const hashProvider = overrides?.hashProvider ?? new BcryptHashProvider()
18
31
  const tokenService = overrides?.tokenService ?? new JoseTokenService(
@@ -1,9 +1,14 @@
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
+
1
5
  import { createMiddleware } from 'hono/factory'
2
6
  import type { IActivityLogger, INotificationService, IClock, IIdGenerator } from '@beechcms/core'
3
7
  import { SystemClock, SystemIdGenerator } from '@beechcms/core'
4
8
  import type { AppEnv } from '../types'
5
9
  import { D1ActivityLogger } from '../shared/d1-activity-logger'
6
10
  import { BackgroundNotificationService } from '../shared/background-notification-service'
11
+ import { QStashNotificationService } from '../shared/qstash-notification-service'
7
12
 
8
13
  export interface ObservabilityOverrides {
9
14
  activityLogger?: IActivityLogger
@@ -40,9 +45,22 @@ export const observabilityMiddleware = (overrides?: ObservabilityOverrides) => {
40
45
  overrides?.activityLogger ??
41
46
  new D1ActivityLogger(context.env.DB, resolvedClock, resolvedIdGenerator, scheduleBackgroundTask)
42
47
 
43
- const notificationService =
44
- overrides?.notificationService ??
45
- new BackgroundNotificationService(context.get('notificationRepository'), scheduleBackgroundTask)
48
+ let notificationService: INotificationService
49
+ if (overrides?.notificationService) {
50
+ notificationService = overrides.notificationService
51
+ } else if (context.env.QSTASH_TOKEN && context.env.APP_URL) {
52
+ notificationService = new QStashNotificationService(
53
+ context.env.QSTASH_TOKEN,
54
+ context.env.APP_URL,
55
+ scheduleBackgroundTask,
56
+ context.env.QSTASH_URL
57
+ )
58
+ } else {
59
+ notificationService = new BackgroundNotificationService(
60
+ context.get('notificationRepository'),
61
+ scheduleBackgroundTask
62
+ )
63
+ }
46
64
 
47
65
  context.set('activityLogger', activityLogger)
48
66
  context.set('notificationService', notificationService)
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { createMiddleware } from 'hono/factory'
3
7
  import type { IRateLimiter } from '@beechcms/core'