@beechcms/api 0.5.0 → 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 (206) 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/package.json +13 -9
  9. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
  10. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
  11. package/src/auth/bcrypt-hash-provider.ts +6 -2
  12. package/src/auth/constants.ts +4 -0
  13. package/src/auth/generate-refresh-token.test.ts +8 -4
  14. package/src/auth/hash-provider.test.ts +14 -5
  15. package/src/auth/jose-token-service.ts +7 -0
  16. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  17. package/src/auth/login.test.ts +9 -1
  18. package/src/auth/login.ts +5 -1
  19. package/src/auth/refresh.ts +7 -1
  20. package/src/auth/token-service.test.ts +9 -1
  21. package/src/factory.ts +37 -16
  22. package/src/features/automations/__tests__/action-executors.test.ts +59 -72
  23. package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
  24. package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
  25. package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
  26. package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
  27. package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
  28. package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
  29. package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
  30. package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
  31. package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
  32. package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
  33. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  34. package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
  35. package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
  36. package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
  37. package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
  38. package/src/features/automations/action-executors/index.ts +5 -1
  39. package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
  40. package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
  41. package/src/features/automations/action-executors/webhook.executor.ts +55 -13
  42. package/src/features/automations/automation-runner.ts +4 -0
  43. package/src/features/automations/automation-runner.utils.ts +4 -0
  44. package/src/features/automations/automations.handler.ts +4 -0
  45. package/src/features/automations/automations.schema.ts +19 -2
  46. package/src/features/automations/context-resolver.ts +4 -0
  47. package/src/features/automations/cron-runner.ts +4 -0
  48. package/src/features/automations/cron-runner.utils.ts +4 -0
  49. package/src/features/automations/filter-translation.ts +4 -0
  50. package/src/features/automations/index.ts +4 -0
  51. package/src/features/automations/template-grammar.ts +4 -0
  52. package/src/features/automations/var-access-resolver.ts +4 -0
  53. package/src/features/automations/when-evaluator.ts +4 -0
  54. package/src/features/automations/when-pushdown.ts +4 -0
  55. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  56. package/src/features/backrefs/backrefs.handler.ts +168 -0
  57. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  58. package/src/features/backrefs/index.ts +5 -0
  59. package/src/features/content/constants.ts +6 -0
  60. package/src/features/content/handlers/bulk.handler.ts +185 -0
  61. package/src/features/content/handlers/create.ts +19 -55
  62. package/src/features/content/handlers/delete.ts +12 -37
  63. package/src/features/content/handlers/facets.ts +4 -0
  64. package/src/features/content/handlers/get.ts +4 -0
  65. package/src/features/content/handlers/helpers.test.ts +180 -0
  66. package/src/features/content/handlers/helpers.ts +93 -0
  67. package/src/features/content/handlers/list.ts +105 -19
  68. package/src/features/content/handlers/update.ts +19 -64
  69. package/src/features/content/index.ts +6 -0
  70. package/src/features/draft/draft.handler.ts +33 -8
  71. package/src/features/draft/draft.middleware.ts +4 -0
  72. package/src/features/draft/index.ts +4 -0
  73. package/src/features/email/email.provider.ts +4 -0
  74. package/src/features/email/email.service.ts +50 -45
  75. package/src/features/email/email.types.ts +12 -1
  76. package/src/features/email/index.ts +4 -0
  77. package/src/features/email/providers/resend.ts +4 -0
  78. package/src/features/email/providers/smtp.ts +55 -0
  79. package/src/features/email/templates/automation-mail.ts +4 -0
  80. package/src/features/email/templates/password-changed.ts +4 -0
  81. package/src/features/email/templates/password-reset.ts +4 -0
  82. package/src/features/email/templates/shell.ts +4 -0
  83. package/src/features/notifications/index.ts +4 -0
  84. package/src/features/notifications/notifications.handler.ts +4 -0
  85. package/src/features/password-reset/index.ts +5 -1
  86. package/src/features/password-reset/request.ts +12 -2
  87. package/src/features/password-reset/reset.ts +12 -2
  88. package/src/features/rotate-field/index.ts +4 -0
  89. package/src/features/rotate-field/rotate-field.handler.ts +4 -0
  90. package/src/features/rotate-field/rotate-field.schema.ts +4 -0
  91. package/src/features/schema/schema.handler.ts +118 -3
  92. package/src/features/seeds/index.ts +5 -0
  93. package/src/features/seeds/seeds.handler.test.ts +632 -0
  94. package/src/features/seeds/seeds.handler.ts +693 -0
  95. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  96. package/src/features/settings/settings.handler.ts +98 -8
  97. package/src/features/setup/index.ts +124 -11
  98. package/src/features/stats/index.ts +4 -0
  99. package/src/features/stats/stats.handler.ts +11 -21
  100. package/src/features/webhooks/index.ts +63 -0
  101. package/src/index.ts +28 -19
  102. package/src/media-utils.ts +4 -0
  103. package/src/middleware/auth-providers.middleware.ts +13 -0
  104. package/src/middleware/observability.middleware.ts +21 -3
  105. package/src/middleware/rate-limit.middleware.ts +4 -0
  106. package/src/middleware/repository.middleware.ts +25 -2
  107. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  108. package/src/middleware/seed-registry.middleware.ts +21 -0
  109. package/src/middleware/storage.middleware.ts +4 -0
  110. package/src/middleware.ts +5 -7
  111. package/src/public/access-policy.ts +4 -0
  112. package/src/public/api-key-middleware.ts +4 -0
  113. package/src/public/cache-utils.ts +4 -0
  114. package/src/public/entry-projection.ts +4 -0
  115. package/src/public/idempotency.ts +4 -0
  116. package/src/public/index.ts +4 -0
  117. package/src/public/problem-details.ts +71 -0
  118. package/src/public/public-add.ts +4 -0
  119. package/src/public/public-edit.ts +4 -0
  120. package/src/public/public-errors.ts +4 -0
  121. package/src/public/public-read.ts +4 -0
  122. package/src/public/public-routes.ts +4 -0
  123. package/src/public/query-builder.test.ts +4 -0
  124. package/src/public/query-builder.ts +4 -0
  125. package/src/public/rate-limit-middleware.ts +4 -0
  126. package/src/public/read-list.ts +4 -0
  127. package/src/public/read-single.ts +4 -0
  128. package/src/public/response-builder.ts +4 -0
  129. package/src/public/sanitize.ts +4 -0
  130. package/src/public/slug-utils.ts +4 -0
  131. package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
  132. package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
  133. package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
  134. package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
  135. package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
  136. package/src/rate-limit/no-op-rate-limiter.ts +4 -0
  137. package/src/search-utils.test.ts +4 -0
  138. package/src/search-utils.ts +4 -0
  139. package/src/search.ts +4 -0
  140. package/src/shared/apply-policies.test.ts +4 -0
  141. package/src/shared/apply-policies.ts +4 -0
  142. package/src/shared/automations.repository.d1.ts +4 -0
  143. package/src/shared/background-notification-service.test.ts +4 -0
  144. package/src/shared/background-notification-service.ts +4 -0
  145. package/src/shared/base.repository.d1.ts +4 -0
  146. package/src/shared/content-utils.test.ts +4 -0
  147. package/src/shared/content-utils.ts +4 -0
  148. package/src/shared/content.repository.d1.test.ts +147 -1
  149. package/src/shared/content.repository.d1.ts +535 -66
  150. package/src/shared/d1-activity-log.repository.test.ts +4 -0
  151. package/src/shared/d1-activity-log.repository.ts +4 -0
  152. package/src/shared/d1-activity-logger.test.ts +4 -0
  153. package/src/shared/d1-activity-logger.ts +4 -0
  154. package/src/shared/d1-analytics.repository.test.ts +4 -0
  155. package/src/shared/d1-analytics.repository.ts +4 -0
  156. package/src/shared/d1-content-scan.repository.test.ts +9 -5
  157. package/src/shared/d1-content-scan.repository.ts +15 -7
  158. package/src/shared/d1-notification.repository.test.ts +4 -0
  159. package/src/shared/d1-notification.repository.ts +4 -0
  160. package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
  161. package/src/shared/d1-password-reset-token.repository.ts +4 -0
  162. package/src/shared/d1-search.repository.test.ts +4 -0
  163. package/src/shared/d1-search.repository.ts +4 -0
  164. package/src/shared/d1-session.repository.test.ts +4 -0
  165. package/src/shared/d1-session.repository.ts +4 -0
  166. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  167. package/src/shared/d1-user.repository.test.ts +8 -4
  168. package/src/shared/d1-user.repository.ts +15 -5
  169. package/src/shared/d1-widget.repository.test.ts +4 -0
  170. package/src/shared/d1-widget.repository.ts +4 -0
  171. package/src/shared/demo-data-sql.ts +54 -0
  172. package/src/shared/demo-data.repository.d1.ts +20 -0
  173. package/src/shared/execution-context-scheduler.ts +4 -0
  174. package/src/shared/fixed-clock.ts +4 -0
  175. package/src/shared/fts-sync.ts +4 -0
  176. package/src/shared/idempotency.repository.d1.test.ts +4 -0
  177. package/src/shared/idempotency.repository.d1.ts +4 -0
  178. package/src/shared/in-memory-activity-logger.ts +4 -0
  179. package/src/shared/in-memory-notification-service.ts +4 -0
  180. package/src/shared/in-memory-seed.repository.ts +51 -0
  181. package/src/shared/media.repository.d1.test.ts +4 -0
  182. package/src/shared/media.repository.d1.ts +4 -0
  183. package/src/shared/qstash-notification-service.test.ts +67 -0
  184. package/src/shared/qstash-notification-service.ts +55 -0
  185. package/src/shared/query-utils.ts +4 -0
  186. package/src/shared/request-utils.ts +4 -0
  187. package/src/shared/schema-mutator.d1.ts +64 -0
  188. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  189. package/src/shared/seed-layout.repository.d1.ts +62 -0
  190. package/src/shared/seed-registry-cache.test.ts +68 -0
  191. package/src/shared/seed-registry-cache.ts +49 -0
  192. package/src/shared/seed.repository.d1.test.ts +143 -0
  193. package/src/shared/seed.repository.d1.ts +98 -0
  194. package/src/shared/sequential-id-generator.ts +11 -0
  195. package/src/shared/site-settings.repository.d1.ts +53 -0
  196. package/src/shared/storage/factory.ts +16 -15
  197. package/src/shared/storage/s3-bucket.ts +34 -2
  198. package/src/shared/storage-utils.ts +4 -0
  199. package/src/shared/system-stats.repository.d1.test.ts +4 -0
  200. package/src/shared/system-stats.repository.d1.ts +5 -1
  201. package/src/types.ts +23 -3
  202. package/src/upload.ts +134 -123
  203. package/src/widget.ts +6 -2
  204. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  205. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  206. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -0,0 +1,142 @@
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 { describe, it, expect, vi, beforeEach } from 'vitest'
6
+ import { executeWebhook } from '../action-executors/webhook.executor'
7
+ import type { ResolvedContext } from '../context-resolver'
8
+
9
+ function makeContext(entry: Record<string, unknown> = {}, event = 'create'): ResolvedContext {
10
+ return {
11
+ triggerEntry: entry,
12
+ lookup(parsed) {
13
+ if (parsed.kind === 'simple') {
14
+ const key = parsed.path.startsWith('this.') ? parsed.path.slice(5) : parsed.path
15
+ if (key === 'trigger_event') return event
16
+ return entry[key] ?? null
17
+ }
18
+ return null
19
+ },
20
+ }
21
+ }
22
+
23
+ const baseContext = makeContext({ id: 'entry-1', title: 'Hello' })
24
+
25
+ const baseAction = {
26
+ type: 'webhook' as const,
27
+ url: 'https://example.com/hook',
28
+ body_template: '{"static":true}',
29
+ }
30
+
31
+ function makeOkResponse(status = 200): Response {
32
+ return { ok: true, status } as unknown as Response
33
+ }
34
+
35
+ function makeErrorResponse(status: number): Response {
36
+ return { ok: false, status } as unknown as Response
37
+ }
38
+
39
+ beforeEach(() => {
40
+ vi.restoreAllMocks()
41
+ })
42
+
43
+ describe('executeWebhook', () => {
44
+ it('signs request when WEBHOOK_SECRET is set', async () => {
45
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeOkResponse())
46
+
47
+ await executeWebhook(baseAction, baseContext, { WEBHOOK_SECRET: 'test-secret' })
48
+
49
+ expect(fetchSpy).toHaveBeenCalledOnce()
50
+ const [, init] = fetchSpy.mock.calls[0]
51
+ const headers = init?.headers as Record<string, string>
52
+ expect(headers['X-BeechCMS-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/)
53
+ })
54
+
55
+ it('omits signature header and warns once when WEBHOOK_SECRET is absent', async () => {
56
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeOkResponse())
57
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
58
+
59
+ await executeWebhook(baseAction, baseContext, {})
60
+ await executeWebhook(baseAction, baseContext, {})
61
+
62
+ const [, init] = fetchSpy.mock.calls[0]
63
+ const headers = init?.headers as Record<string, string>
64
+ expect(headers['X-BeechCMS-Signature']).toBeUndefined()
65
+ expect(warnSpy).toHaveBeenCalledOnce()
66
+ expect(warnSpy.mock.calls[0][0]).toContain('WEBHOOK_SECRET')
67
+ })
68
+
69
+ it('overwrites user-supplied X-BeechCMS-Signature header with computed value', async () => {
70
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeOkResponse())
71
+
72
+ const action = {
73
+ ...baseAction,
74
+ headers: { 'X-BeechCMS-Signature': 'sha256=user-supplied-value' },
75
+ }
76
+
77
+ await executeWebhook(action, baseContext, { WEBHOOK_SECRET: 'test-secret' })
78
+
79
+ const [, init] = fetchSpy.mock.calls[0]
80
+ const headers = init?.headers as Record<string, string>
81
+ expect(headers['X-BeechCMS-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/)
82
+ expect(headers['X-BeechCMS-Signature']).not.toBe('sha256=user-supplied-value')
83
+ })
84
+
85
+ it('throws with status code when endpoint responds with 500', async () => {
86
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeErrorResponse(500))
87
+
88
+ await expect(executeWebhook(baseAction, baseContext, {}))
89
+ .rejects.toThrow('500')
90
+ })
91
+
92
+ it('interpolates body_template placeholders before sending', async () => {
93
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeOkResponse())
94
+
95
+ const action = { ...baseAction, body_template: '{"event":"{{trigger_event}}","id":"{{id}}"}' }
96
+ const ctx = makeContext({ id: 'abc-123' }, 'update')
97
+
98
+ await executeWebhook(action, ctx, {})
99
+
100
+ const [, init] = fetchSpy.mock.calls[0]
101
+ const body = init?.body as string
102
+ expect(body).toContain('abc-123')
103
+ expect(body).toContain('update')
104
+ })
105
+
106
+ it('recalculates HMAC and matches X-BeechCMS-Signature header', async () => {
107
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeOkResponse())
108
+ const secret = 'verify-secret'
109
+
110
+ await executeWebhook(baseAction, baseContext, { WEBHOOK_SECRET: secret })
111
+
112
+ const [, init] = fetchSpy.mock.calls[0]
113
+ const headers = init?.headers as Record<string, string>
114
+ const sentBody = init?.body as string
115
+ const sentSig = headers['X-BeechCMS-Signature']
116
+
117
+ const encoder = new TextEncoder()
118
+ const key = await crypto.subtle.importKey(
119
+ 'raw',
120
+ encoder.encode(secret),
121
+ { name: 'HMAC', hash: 'SHA-256' },
122
+ false,
123
+ ['sign'],
124
+ )
125
+ const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(sentBody))
126
+ const expected = 'sha256=' + Array.from(new Uint8Array(sig))
127
+ .map((b) => b.toString(16).padStart(2, '0'))
128
+ .join('')
129
+
130
+ expect(sentSig).toBe(expected)
131
+ })
132
+
133
+ it('throws timeout error when fetch takes longer than 8 seconds', async () => {
134
+ vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
135
+ const err = new DOMException('The operation was aborted.', 'TimeoutError')
136
+ return Promise.reject(err)
137
+ })
138
+
139
+ await expect(executeWebhook(baseAction, baseContext, {}))
140
+ .rejects.toThrow('timed out after 8000ms')
141
+ })
142
+ })
@@ -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 { describe, it, expect } from 'vitest'
2
6
  import type { WhenNode } from '@beechcms/core'
3
7
  import { evaluateWhen } from '../when-evaluator'
@@ -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 { describe, it, expect } from 'vitest'
2
6
  import type { WhenNode, Seed } from '@beechcms/core'
3
7
  import { extractPushdownFilters } from '../when-pushdown'
@@ -11,7 +15,7 @@ function makeSeed(aliases: string[] = []): Seed {
11
15
  slug: 'orders',
12
16
  label: 'Orders',
13
17
  displayNameAlias: aliases[0] ?? 'id',
14
- branches: aliases.map((alias) => ({ alias, label: alias, type: 'text' as const })),
18
+ branches: aliases.map((alias, i) => ({ id: `br_0${i + 1}`, alias, label: alias, type: 'text' as const })),
15
19
  }
16
20
  }
17
21
 
@@ -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 type { AutomationAction, ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
2
6
 
3
7
  type CreateEntryAction = Extract<AutomationAction, { type: 'create_entry' }>
@@ -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 type { AutomationAction, ContentRepository, Seed } from '@beechcms/core'
2
6
  import type { ResolvedContext } from '../context-resolver'
3
7
  import { interpolate } from '../automation-runner.utils'
@@ -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 type { AutomationAction, ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
2
6
  import type { ResolvedContext } from '../context-resolver'
3
7
  import { executeWebhook } from './webhook.executor'
@@ -21,7 +25,7 @@ export interface ActionContext {
21
25
  export async function executeAction(action: AutomationAction, ctx: ActionContext): Promise<void> {
22
26
  switch (action.type) {
23
27
  case 'set_variable': return executeSetVariable(action, ctx)
24
- case 'webhook': return executeWebhook(action, ctx.context)
28
+ case 'webhook': return executeWebhook(action, ctx.context, ctx.env)
25
29
  case 'send_mail': return executeSendMail(action, ctx.context, ctx.env)
26
30
  case 'edit_field': return executeEditField(action, ctx.entry, ctx.context, ctx.repository, ctx.seed)
27
31
  case 'create_entry': return executeCreateEntry(action, ctx.entry, ctx.repository, ctx.getSeed, ctx.idGenerator)
@@ -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 type { AutomationAction } from '@beechcms/core'
2
6
  import type { ResolvedContext } from '../context-resolver'
3
7
  import { sendAutomationMail } from '../../email'
@@ -21,19 +25,26 @@ export async function executeSendMail(
21
25
  console.warn('[automations:send_mail] placeholders evaluated to default value due to missing concrete data:', missingFields)
22
26
  }
23
27
 
28
+ const useSmtp = env.EMAIL_PROVIDER === 'smtp'
24
29
  const apiKey = env.EMAIL_API_KEY ?? env.RESEND_API_KEY
25
- if (!apiKey) {
30
+ if (!useSmtp && !apiKey) {
26
31
  console.error('[automations:send_mail] Execution skipped: Email API key is missing from the environment configuration.')
27
32
  return
28
33
  }
29
34
 
35
+ const smtpBaseUrl = env.SMTP_HOST
36
+ ? `http://${env.SMTP_HOST}:${env.SMTP_PORT ?? '8025'}`
37
+ : undefined
38
+
30
39
  try {
31
40
  await sendAutomationMail({
32
41
  to,
33
42
  subject,
34
43
  body,
35
- apiKey,
44
+ apiKey: apiKey ?? '',
36
45
  from: env.EMAIL_FROM,
46
+ provider: env.EMAIL_PROVIDER as 'smtp' | 'resend' | undefined,
47
+ smtpBaseUrl,
37
48
  })
38
49
  } catch (error) {
39
50
  console.error('[automations:send_mail] failed to send automation email via provider:', error)
@@ -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 type { AutomationAction, ContentRepository, Seed } from '@beechcms/core'
2
6
  import type { ResolvedContext } from '../context-resolver'
3
7
  import { conditionToFilterGroup } from '../filter-translation'
@@ -24,8 +28,17 @@ export async function executeSetVariable(
24
28
  return
25
29
  }
26
30
 
27
- if (action.fixed_id !== undefined) {
28
- const resolvedId = interpolate(action.fixed_id, ctx.context)
31
+ // ── legacy read normalisation ─────────────────────────────────────────────
32
+ let effectiveFixedId = action.fixed_id
33
+ if (effectiveFixedId === undefined && action.load_type === 'fruit') {
34
+ const idFilter = (action.filters ?? []).find(
35
+ (f) => f.field === 'id' && f.op === 'eq' && typeof f.value === 'string',
36
+ )
37
+ if (idFilter) effectiveFixedId = String(idFilter.value)
38
+ }
39
+
40
+ if (effectiveFixedId !== undefined) {
41
+ const resolvedId = interpolate(effectiveFixedId, ctx.context)
29
42
  const { items } = await ctx.repository.findMany(targetSeed, {
30
43
  filters: [{ column: 'id', type: 'system', conditions: [{ op: 'eq', value: resolvedId }] }],
31
44
  status: null,
@@ -1,25 +1,67 @@
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 type { AutomationAction } from '@beechcms/core'
2
6
  import type { ResolvedContext } from '../context-resolver'
3
7
  import { interpolate } from '../automation-runner.utils'
4
8
 
5
9
  type WebhookAction = Extract<AutomationAction, { type: 'webhook' }>
6
10
 
11
+ const WEBHOOK_TIMEOUT_MS = 8_000
12
+
13
+ let warnedNoSecret = false
14
+
15
+ async function signBody(body: string, secret: string): Promise<string> {
16
+ const encoder = new TextEncoder()
17
+ const key = await crypto.subtle.importKey(
18
+ 'raw',
19
+ encoder.encode(secret),
20
+ { name: 'HMAC', hash: 'SHA-256' },
21
+ false,
22
+ ['sign'],
23
+ )
24
+ const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(body))
25
+ return 'sha256=' + Array.from(new Uint8Array(sig))
26
+ .map((b) => b.toString(16).padStart(2, '0'))
27
+ .join('')
28
+ }
29
+
7
30
  export async function executeWebhook(
8
31
  action: WebhookAction,
9
32
  context: ResolvedContext,
33
+ env: Record<string, string | undefined>,
10
34
  ): Promise<void> {
11
- const entry = context.triggerEntry ?? {}
12
- const body = action.body_template
13
- ? interpolate(action.body_template, context)
14
- : JSON.stringify(entry)
15
-
16
- const response = await fetch(action.url, {
17
- method: action.method ?? 'POST',
18
- headers: { 'Content-Type': 'application/json', ...(action.headers ?? {}) },
19
- body,
20
- })
21
-
22
- if (!response.ok) {
23
- throw new Error(`Webhook ${action.url} responded ${response.status}`)
35
+ const body = interpolate(action.body_template ?? '{}', context)
36
+
37
+ const secret = env.WEBHOOK_SECRET
38
+ let signatureHeader: Record<string, string> = {}
39
+ if (secret) {
40
+ signatureHeader = { 'X-BeechCMS-Signature': await signBody(body, secret) }
41
+ } else if (!warnedNoSecret) {
42
+ warnedNoSecret = true
43
+ console.warn('[webhook] WEBHOOK_SECRET not set — outgoing webhooks are unsigned')
44
+ }
45
+
46
+ try {
47
+ const response = await fetch(action.url, {
48
+ method: action.method ?? 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ ...(action.headers ?? {}),
52
+ ...signatureHeader,
53
+ },
54
+ body,
55
+ signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
56
+ })
57
+
58
+ if (!response.ok) {
59
+ throw new Error(`Webhook ${action.url} responded ${response.status}`)
60
+ }
61
+ } catch (err) {
62
+ if (err instanceof Error && err.name === 'TimeoutError') {
63
+ throw new Error(`Webhook ${action.url} timed out after ${WEBHOOK_TIMEOUT_MS}ms`)
64
+ }
65
+ throw err
24
66
  }
25
67
  }
@@ -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 type {
2
6
  IAutomationRunner,
3
7
  IAutomationRepository,
@@ -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 type { ResolvedContext } from './context-resolver'
2
6
  import { parseTemplateKey } from './template-grammar'
3
7
 
@@ -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'
@@ -1,6 +1,11 @@
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
  // Mirror: apps/dashboard/src/features/automations/schema/automation.schema.ts — keep structurally identical.
2
6
  import { z } from 'zod'
3
7
  import type { WhenNode } from '@beechcms/core'
8
+ import { isPrivateHost } from '@beechcms/core'
4
9
  import { AUTOMATION_RESERVED_WORDS } from './template-grammar'
5
10
 
6
11
  // ---------------------------------------------------------------------------
@@ -71,14 +76,26 @@ const setVariableActionSchema = z.object({
71
76
  filters: z.array(setVariableFilterSchema).default([]),
72
77
  order_by: z.string().optional(),
73
78
  order: z.enum(['asc', 'desc']).optional(),
79
+ load_type: z.enum(['fruit', 'branch']).optional(), // accepted but ignored at runtime
74
80
  })
75
81
 
76
82
  const webhookActionSchema = z.object({
77
83
  type: z.literal('webhook'),
78
- url: z.string().url(),
84
+ url: z
85
+ .string()
86
+ .url()
87
+ .refine((u) => {
88
+ try { return new URL(u).protocol === 'https:' } catch { return false }
89
+ }, { message: 'automations.editor.errors.webhookHttpsRequired' })
90
+ .refine((u) => {
91
+ try { return !isPrivateHost(new URL(u).hostname) } catch { return false }
92
+ }, { message: 'automations.editor.errors.webhookPrivateHostBlocked' }),
79
93
  method: z.enum(['POST', 'GET', 'PUT']).optional(),
80
94
  headers: z.record(z.string(), z.string()).optional(),
81
- body_template: z.string().optional(),
95
+ body_template: z.preprocess(
96
+ (v) => (v == null ? '' : v),
97
+ z.string().min(1, 'automations.editor.errors.bodyRequired'),
98
+ ),
82
99
  })
83
100
 
84
101
  const sendMailActionSchema = z.object({
@@ -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 type { Automation } from '@beechcms/core'
2
6
  import type { AutomationContextSelector, ParsedKey } from './template-grammar'
3
7
  import { resolvePath } from './automation-runner.utils'
@@ -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 type {
2
6
  Automation,
3
7
  ContentRepository,
@@ -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
  export function cronMatches(expression: string | null, scheduledTime: number): boolean {
2
6
  if (!expression) return false
3
7
  const parts = expression.trim().split(/\s+/)
@@ -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
  * Shared helper: converts a TriggerCondition into a FilterGroup accepted by
3
7
  * ContentRepository.findMany. Used by cron-runner and context-resolver.
@@ -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
  export { AutomationRunner } from './automation-runner'
2
6
  export type { AutomationRunnerDeps } from './automation-runner'
3
7
  export { runCronAutomations } from './cron-runner'
@@ -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
  * Pure template-key parser for the automation template engine.
3
7
  *
@@ -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 type { Seed } from '@beechcms/core'
2
6
  import type { ParsedKey, InlineCondition } from './template-grammar'
3
7
  import { materializeCollection } from './action-executors/set-variable.executor'
@@ -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 type { WhenNode, WhenPredicate, WhenGroup, WhenOperand } from '@beechcms/core'
2
6
  import type { ResolvedContext } from './context-resolver'
3
7
  import { parseTemplateKey } from './template-grammar'
@@ -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 type { WhenNode, WhenPredicate, TriggerCondition, FilterGroup, Seed } from '@beechcms/core'
2
6
  import { conditionToFilterGroup } from './filter-translation'
3
7