@beechcms/api 0.4.0 → 0.4.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 (138) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/auth/bcrypt-hash-provider.ts +20 -0
  8. package/src/auth/constants.ts +3 -3
  9. package/src/auth/generate-refresh-token.test.ts +19 -0
  10. package/src/auth/hash-provider.test.ts +46 -0
  11. package/src/auth/in-memory-hash-provider.ts +13 -0
  12. package/src/auth/jose-token-service.ts +55 -0
  13. package/src/auth/login.test.ts +92 -0
  14. package/src/auth/login.ts +15 -32
  15. package/src/auth/refresh.ts +0 -122
  16. package/src/auth/static-token-service.ts +18 -0
  17. package/src/auth/token-service.test.ts +82 -0
  18. package/src/factory.ts +80 -80
  19. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  20. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  21. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  22. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  23. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  24. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  25. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  26. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  27. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  28. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  29. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  30. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  31. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  32. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  33. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  34. package/src/features/automations/action-executors/index.ts +33 -0
  35. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  36. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  37. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  38. package/src/features/automations/automation-runner.ts +81 -0
  39. package/src/features/automations/automation-runner.utils.ts +43 -0
  40. package/src/features/automations/automations.handler.ts +193 -0
  41. package/src/features/automations/automations.schema.ts +160 -0
  42. package/src/features/automations/context-resolver.ts +148 -0
  43. package/src/features/automations/cron-runner.ts +136 -0
  44. package/src/features/automations/cron-runner.utils.ts +40 -0
  45. package/src/features/automations/filter-translation.ts +42 -0
  46. package/src/features/automations/index.ts +12 -0
  47. package/src/features/automations/template-grammar.ts +241 -0
  48. package/src/features/automations/var-access-resolver.ts +136 -0
  49. package/src/features/automations/when-evaluator.ts +83 -0
  50. package/src/features/automations/when-pushdown.ts +53 -0
  51. package/src/features/content/handlers/create.ts +22 -10
  52. package/src/features/content/handlers/delete.ts +21 -10
  53. package/src/features/content/handlers/update.ts +21 -9
  54. package/src/features/draft/draft.handler.ts +51 -153
  55. package/src/features/draft/draft.middleware.ts +62 -0
  56. package/src/features/email/email.service.ts +13 -0
  57. package/src/features/email/email.types.ts +10 -0
  58. package/src/features/email/index.ts +2 -1
  59. package/src/features/email/templates/automation-mail.ts +15 -0
  60. package/src/features/notifications/notifications.handler.ts +25 -54
  61. package/src/features/password-reset/request.ts +17 -41
  62. package/src/features/password-reset/reset.ts +18 -54
  63. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  64. package/src/features/schema/schema.handler.ts +1 -1
  65. package/src/features/settings/settings.handler.ts +64 -176
  66. package/src/features/setup/index.ts +12 -17
  67. package/src/features/stats/stats.handler.ts +110 -138
  68. package/src/index.ts +40 -8
  69. package/src/middleware/auth-providers.middleware.ts +32 -0
  70. package/src/middleware/observability.middleware.ts +52 -0
  71. package/src/middleware/rate-limit.middleware.ts +41 -0
  72. package/src/middleware/repository.middleware.ts +72 -5
  73. package/src/middleware.ts +15 -35
  74. package/src/public/cache-utils.ts +34 -0
  75. package/src/public/entry-projection.ts +42 -0
  76. package/src/public/idempotency.ts +19 -0
  77. package/src/public/problem-details.ts +5 -0
  78. package/src/public/public-add.ts +110 -166
  79. package/src/public/public-edit.ts +4 -3
  80. package/src/public/public-read.ts +59 -216
  81. package/src/public/public-routes.ts +2 -2
  82. package/src/public/query-builder.test.ts +220 -0
  83. package/src/public/rate-limit-middleware.ts +7 -19
  84. package/src/public/read-list.ts +50 -0
  85. package/src/public/read-single.ts +44 -0
  86. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  87. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  88. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  89. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  90. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  91. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  92. package/src/search-utils.test.ts +207 -0
  93. package/src/search-utils.ts +18 -1
  94. package/src/search.ts +24 -35
  95. package/src/shared/apply-policies.test.ts +77 -0
  96. package/src/shared/automations.repository.d1.ts +146 -0
  97. package/src/shared/background-notification-service.test.ts +58 -0
  98. package/src/shared/background-notification-service.ts +48 -0
  99. package/src/shared/content-utils.test.ts +161 -0
  100. package/src/shared/content.repository.d1.test.ts +312 -0
  101. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  102. package/src/shared/d1-activity-log.repository.ts +101 -0
  103. package/src/shared/d1-activity-logger.test.ts +82 -0
  104. package/src/shared/d1-activity-logger.ts +63 -0
  105. package/src/shared/d1-analytics.repository.test.ts +74 -0
  106. package/src/shared/d1-analytics.repository.ts +81 -0
  107. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  108. package/src/shared/d1-content-scan.repository.ts +29 -0
  109. package/src/shared/d1-notification.repository.test.ts +124 -0
  110. package/src/shared/d1-notification.repository.ts +114 -0
  111. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  112. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  113. package/src/shared/d1-search.repository.test.ts +83 -0
  114. package/src/shared/d1-search.repository.ts +84 -0
  115. package/src/shared/d1-session.repository.test.ts +121 -0
  116. package/src/shared/d1-session.repository.ts +98 -0
  117. package/src/shared/d1-user.repository.test.ts +147 -0
  118. package/src/shared/d1-user.repository.ts +109 -0
  119. package/src/shared/d1-widget.repository.test.ts +217 -0
  120. package/src/shared/d1-widget.repository.ts +337 -0
  121. package/src/shared/execution-context-scheduler.ts +9 -0
  122. package/src/shared/fixed-clock.ts +21 -0
  123. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  124. package/src/shared/in-memory-activity-logger.ts +15 -0
  125. package/src/shared/in-memory-notification-service.ts +15 -0
  126. package/src/shared/media.repository.d1.test.ts +103 -0
  127. package/src/shared/media.repository.d1.ts +1 -1
  128. package/src/shared/request-utils.ts +22 -0
  129. package/src/shared/sequential-id-generator.ts +22 -0
  130. package/src/shared/storage-utils.ts +3 -3
  131. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  132. package/src/types.ts +24 -3
  133. package/src/upload.ts +17 -9
  134. package/src/widget.ts +112 -253
  135. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
  136. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  137. package/src/shared/activity-logger.ts +0 -79
  138. package/src/shared/notification-service.ts +0 -56
package/src/factory.ts CHANGED
@@ -3,24 +3,19 @@ import { Hono } from 'hono'
3
3
  import { cors } from 'hono/cors'
4
4
  import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
5
5
  import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
6
+ import { sha256hex, SystemClock, SystemIdGenerator, SeedRegistry } from '@beechcms/core'
6
7
  import type { Env, Variables } from './types'
8
+ import { getClientIp } from './shared/request-utils'
7
9
 
8
10
  // Imports delle rotte e middleware
9
11
  import { AUTH_ERRORS } from './auth/constants'
10
12
  import {
11
13
  parseLoginBody,
12
14
  validateLoginInput,
13
- findUserByEmail,
14
15
  verifyPassword,
15
16
  DUMMY_PASSWORD_HASH,
16
17
  } from './auth/login'
17
- import {
18
- generateRefreshToken,
19
- saveRefreshToken,
20
- generateAccessToken,
21
- validateRefreshToken,
22
- revokeRefreshToken,
23
- } from './auth/refresh'
18
+ import { generateRefreshToken } from './auth/refresh'
24
19
  import { authMiddleware } from './middleware'
25
20
  import contentFeature from './features/content'
26
21
  import { widgetApp } from './widget'
@@ -31,12 +26,16 @@ import { draftApp } from './features/draft'
31
26
  import { settingsApp } from './features/settings/settings.handler'
32
27
  import { schemaApp } from './features/schema/schema.handler'
33
28
  import { notificationsApp } from './features/notifications'
29
+ import { automationsApp } from './features/automations'
34
30
  import { statsApp } from './features/stats'
35
31
  import { uploadRoutes, serveMediaHandler } from './upload'
36
32
  import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
37
33
  import { searchRouter } from "./search"
38
34
  import { repositoryMiddleware } from './middleware/repository.middleware'
39
35
  import { storageMiddleware } from './middleware/storage.middleware'
36
+ import { authProvidersMiddleware } from './middleware/auth-providers.middleware'
37
+ import { rateLimiterMiddleware } from './middleware/rate-limit.middleware'
38
+ import { observabilityMiddleware } from './middleware/observability.middleware'
40
39
 
41
40
  export interface BeechConfig {
42
41
  seeds: Seed[] | Record<string, Seed>
@@ -55,10 +54,6 @@ function isRequestSecure(url: string): boolean {
55
54
  return new URL(url).protocol === 'https:'
56
55
  }
57
56
 
58
- function getClientIp(headers: Headers): string {
59
- return headers.get('cf-connecting-ip') ?? 'unknown'
60
- }
61
-
62
57
  function getRefreshTokenCookieOptions(secure: boolean) {
63
58
  return {
64
59
  httpOnly: true,
@@ -98,15 +93,14 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
98
93
  const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
99
94
  // Filter out any invalid objects that might have leaked into the registry (e.g. module exports)
100
95
  const validSeeds = seedsArray.filter(s => s && typeof s === 'object' && 'slug' in s)
101
- const registry: Record<string, Seed> = Object.fromEntries(validSeeds.map(s => [s.slug, s]))
102
- const getSeedFn = (slug: string): Seed | null => registry[slug] ?? null
96
+ const seedRegistry = new SeedRegistry(validSeeds)
103
97
 
104
98
  const app = new Hono<{ Bindings: Env; Variables: Variables }>()
105
99
 
106
100
  // 1. Core Middleware (Seeds, CORS, Security)
107
101
  app.use('*', async (context, next) => {
108
- context.set('getSeed', getSeedFn)
109
- context.set('seedRegistry', registry)
102
+ context.set('getSeed', (slug: string) => seedRegistry.get(slug))
103
+ context.set('seedRegistry', seedRegistry)
110
104
  await next()
111
105
  })
112
106
 
@@ -122,6 +116,10 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
122
116
  bucket: config.bucket,
123
117
  }))
124
118
 
119
+ app.use('*', authProvidersMiddleware())
120
+ app.use('*', rateLimiterMiddleware())
121
+ app.use('*', observabilityMiddleware())
122
+
125
123
  app.use('*', async (context, next) => {
126
124
  const isDev = context.env.ENV !== 'production'
127
125
 
@@ -174,27 +172,23 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
174
172
  // 2. Analytics Middleware
175
173
  app.use('/api/*', async (context, next) => {
176
174
  await next()
177
- if (context.req.method !== 'OPTIONS' && context.res.status >= 200 && context.res.status < 300) {
178
- const db = context.env.DB
179
- let executionCtx: any
180
- try { executionCtx = context.executionCtx } catch {}
181
-
182
- if (db && executionCtx) {
183
- const seed = extractPublicSeed(context.req.path)
184
- executionCtx.waitUntil((async () => {
185
- try {
186
- const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
187
- await db.prepare(
188
- `INSERT INTO analytics (day_ts, metric, seed, value)
189
- VALUES (?, 'requests', ?, 1)
190
- ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
191
- ).bind(today, seed).run()
192
- } catch (error) {
193
- console.error('Analytics middleware error:', error)
194
- }
195
- })())
196
- }
197
- }
175
+ if (context.req.method === 'OPTIONS') return
176
+ if (context.res.status < 200 || context.res.status >= 300) return
177
+
178
+ let executionCtx: any
179
+ try { executionCtx = context.executionCtx } catch {}
180
+ if (!executionCtx) return
181
+
182
+ const analyticsRepository = context.get('analyticsRepository')
183
+ if (!analyticsRepository) return
184
+
185
+ const seedSlug = extractPublicSeed(context.req.path)
186
+
187
+ executionCtx.waitUntil(
188
+ analyticsRepository.recordRequest(seedSlug).catch((error: unknown) => {
189
+ console.error('Analytics middleware error:', error)
190
+ })
191
+ )
198
192
  })
199
193
 
200
194
  // 3. Auth Routes
@@ -207,28 +201,27 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
207
201
  const { email, password } = credentials
208
202
  if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
209
203
 
210
- const loginLimiter = context.env.LOGIN_RATE_LIMITER
211
- if (loginLimiter) {
212
- const clientIp = getClientIp(context.req.raw.headers)
213
- const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
214
- if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
215
- }
204
+ const clientIp = getClientIp(context.req)
205
+ const loginRateLimit = await context.get('rateLimiters').getLimiter('login').checkLimit(clientIp)
206
+ if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
216
207
 
217
- const { DB, JWT_SECRET } = context.env
218
- const user = await findUserByEmail(DB, email)
219
- const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
220
- const isValid = await verifyPassword(password, hashToCompare)
208
+ const user = await context.get('userRepository').findByEmail(email)
209
+ const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH
210
+ const isValid = await verifyPassword(password, hashToCompare, context.get('hashProvider'))
221
211
 
222
212
  if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
223
213
 
224
- const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
225
- const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
226
- issuer: context.env.JWT_ISSUER,
227
- audience: context.env.JWT_AUDIENCE,
228
- }, userProfile?.name ?? undefined)
214
+ const accessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
229
215
  const refreshToken = generateRefreshToken()
230
-
231
- await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
216
+ const refreshTokenHash = await sha256hex(refreshToken)
217
+ const nowSeconds = SystemClock.nowSeconds()
218
+
219
+ await context.get('sessionRepository').saveRefreshToken({
220
+ id: SystemIdGenerator.uuid(),
221
+ userId: user.id,
222
+ tokenHash: refreshTokenHash,
223
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
224
+ })
232
225
  setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
233
226
  return context.json({ token: accessToken, expiresIn: '15m' }, 200)
234
227
  } catch (error) {
@@ -238,33 +231,40 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
238
231
 
239
232
  app.post('/auth/refresh', async (context) => {
240
233
  try {
241
- const refreshLimiter = context.env.REFRESH_RATE_LIMITER
242
- if (refreshLimiter) {
243
- const clientIp = getClientIp(context.req.raw.headers)
244
- const { success } = await refreshLimiter.limit({ key: clientIp })
245
- if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
246
- }
234
+ const refreshClientIp = getClientIp(context.req)
235
+ const refreshRateLimit = await context.get('rateLimiters').getLimiter('tokenRefresh').checkLimit(refreshClientIp)
236
+ if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
247
237
 
248
238
  const refreshToken = getCookie(context, 'refresh_token')
249
239
  if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
250
240
 
251
- const { DB, JWT_SECRET } = context.env
252
- const validation = await validateRefreshToken(DB, refreshToken)
253
- if (!validation.valid || !validation.userId) return context.json({ error: 'Invalid refresh token' }, 401)
241
+ const nowSeconds = SystemClock.nowSeconds()
242
+ const tokenHash = await sha256hex(refreshToken)
243
+ const activeSession = await context.get('sessionRepository').findActiveByHash(tokenHash, nowSeconds)
244
+ if (!activeSession) return context.json({ error: 'Invalid refresh token' }, 401)
254
245
 
255
- const user = await DB.prepare('SELECT id, email, name FROM users WHERE id = ? LIMIT 1').bind(validation.userId).first<{ id: string; email: string; name: string | null }>()
256
- if (!user) return context.json({ error: 'User not found' }, 401)
257
-
258
- const revoked = await revokeRefreshToken(DB, refreshToken)
259
- if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
246
+ const user = await context.get('userRepository').findById(activeSession.userId)
247
+ if (!user) {
248
+ await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
249
+ return context.json({ error: 'User not found' }, 401)
250
+ }
260
251
 
261
- const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
262
- issuer: context.env.JWT_ISSUER,
263
- audience: context.env.JWT_AUDIENCE,
264
- }, user.name ?? undefined)
252
+ // Issue new tokens before revoking the old one: if saveRefreshToken fails,
253
+ // the old token stays valid and the user is not locked out.
254
+ const newAccessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
265
255
  const newRefreshToken = generateRefreshToken()
256
+ const newRefreshTokenHash = await sha256hex(newRefreshToken)
257
+
258
+ await context.get('sessionRepository').saveRefreshToken({
259
+ id: SystemIdGenerator.uuid(),
260
+ userId: user.id,
261
+ tokenHash: newRefreshTokenHash,
262
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
263
+ })
264
+
265
+ const revoked = await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
266
+ if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
266
267
 
267
- await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
268
268
  setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
269
269
  return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
270
270
  } catch (error) {
@@ -275,7 +275,11 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
275
275
  app.post('/auth/logout', async (context) => {
276
276
  try {
277
277
  const refreshToken = getCookie(context, 'refresh_token')
278
- if (refreshToken) await revokeRefreshToken(context.env.DB, refreshToken)
278
+ if (refreshToken) {
279
+ const nowSeconds = SystemClock.nowSeconds()
280
+ const tokenHash = await sha256hex(refreshToken)
281
+ await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
282
+ }
279
283
  deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
280
284
  return context.json({ message: 'Logged out' }, 200)
281
285
  } catch (error) {
@@ -289,12 +293,7 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
289
293
 
290
294
  // 5. Protected CMS API
291
295
  const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
292
- apiProtected.use('*', async (context, next) => {
293
- await authMiddleware(context.env.JWT_SECRET, {
294
- issuer: context.env.JWT_ISSUER,
295
- audience: context.env.JWT_AUDIENCE,
296
- })(context, next)
297
- })
296
+ apiProtected.use('*', authMiddleware())
298
297
 
299
298
  apiProtected.route('/settings', settingsApp)
300
299
  apiProtected.route('/schema', schemaApp)
@@ -304,6 +303,7 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
304
303
  apiProtected.route('/content', draftApp)
305
304
  apiProtected.route('/content', contentFeature)
306
305
  apiProtected.route('/widget', widgetApp)
306
+ apiProtected.route('/automations', automationsApp)
307
307
  apiProtected.route('/', uploadRoutes)
308
308
 
309
309
  // 6. Public API (must be registered before apiProtected to avoid auth middleware interception)
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { executeAction } from '../action-executors'
3
+ import type { ActionContext } from '../action-executors'
4
+ import type { ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
5
+
6
+ // ── Shared mock context factory ───────────────────────────────────────────────
7
+
8
+ function makeCtx(overrides: Partial<ActionContext> = {}): ActionContext {
9
+ const { entry: entryOverride, context: contextOverride, ...rest } = overrides
10
+ const entry: Record<string, unknown> = entryOverride ?? { id: 'entry-1', title: 'Test Entry', status: 'published' }
11
+ const context = contextOverride ?? {
12
+ triggerEntry: entry,
13
+ lookup(parsed: import('../template-grammar').ParsedKey, onMissing?: (f: string) => void) {
14
+ if (parsed.kind === 'simple') {
15
+ const val = entry[parsed.path]
16
+ if (val == null && onMissing) onMissing(parsed.path)
17
+ return val
18
+ }
19
+ if (parsed.kind === 'scoped' && parsed.scope === 'this' && parsed.field) {
20
+ return entry[parsed.field]
21
+ }
22
+ return undefined
23
+ },
24
+ }
25
+ return {
26
+ entry,
27
+ env: { RESEND_API_KEY: 'test-key', EMAIL_FROM: 'test@example.com' },
28
+ repository: {
29
+ update: vi.fn().mockResolvedValue(undefined),
30
+ create: vi.fn().mockResolvedValue(undefined),
31
+ } as unknown as ContentRepository,
32
+ getSeed: vi.fn().mockReturnValue({ branches: [] } as unknown as Seed),
33
+ seed: { slug: 'posts', branches: [] } as unknown as Seed,
34
+ idGenerator: { uuid: vi.fn().mockReturnValue('new-id-123') } as unknown as IIdGenerator,
35
+ context,
36
+ variables: {},
37
+ ...rest,
38
+ }
39
+ }
40
+
41
+ // ── webhook ───────────────────────────────────────────────────────────────────
42
+
43
+ describe('webhook executor', () => {
44
+ beforeEach(() => { vi.restoreAllMocks() })
45
+
46
+ it('POSTs to URL with JSON body derived from entry when no body_template', async () => {
47
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 })
48
+ vi.stubGlobal('fetch', fetchMock)
49
+
50
+ const ctx = makeCtx()
51
+ await executeAction({ type: 'webhook', url: 'https://example.com/hook' }, ctx)
52
+
53
+ expect(fetchMock).toHaveBeenCalledOnce()
54
+ const [url, init] = fetchMock.mock.calls[0]
55
+ expect(url).toBe('https://example.com/hook')
56
+ expect(init.method).toBe('POST')
57
+ expect(JSON.parse(init.body)).toMatchObject({ id: 'entry-1', title: 'Test Entry' })
58
+ })
59
+
60
+ it('interpolates body_template with entry values', async () => {
61
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true })
62
+ vi.stubGlobal('fetch', fetchMock)
63
+
64
+ const ctx = makeCtx()
65
+ await executeAction({ type: 'webhook', url: 'https://example.com/hook', body_template: '{"t":"{{title}}"}' }, ctx)
66
+
67
+ const [, init] = fetchMock.mock.calls[0]
68
+ expect(init.body).toBe('{"t":"Test Entry"}')
69
+ })
70
+
71
+ it('merges custom headers', async () => {
72
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true })
73
+ vi.stubGlobal('fetch', fetchMock)
74
+
75
+ const ctx = makeCtx()
76
+ await executeAction({ type: 'webhook', url: 'https://x.com', headers: { 'X-Token': 'abc' } }, ctx)
77
+
78
+ const [, init] = fetchMock.mock.calls[0]
79
+ expect(init.headers['X-Token']).toBe('abc')
80
+ })
81
+
82
+ it('throws when response is not ok', async () => {
83
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }))
84
+ const ctx = makeCtx()
85
+ await expect(executeAction({ type: 'webhook', url: 'https://x.com' }, ctx)).rejects.toThrow('500')
86
+ })
87
+
88
+ it('uses custom HTTP method', async () => {
89
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true })
90
+ vi.stubGlobal('fetch', fetchMock)
91
+ const ctx = makeCtx()
92
+ await executeAction({ type: 'webhook', url: 'https://x.com', method: 'PUT' }, ctx)
93
+ expect(fetchMock.mock.calls[0][1].method).toBe('PUT')
94
+ })
95
+ })
96
+
97
+ // ── send_mail ─────────────────────────────────────────────────────────────────
98
+
99
+ vi.mock('../../email', () => ({
100
+ sendAutomationMail: vi.fn().mockResolvedValue(undefined),
101
+ }))
102
+
103
+ describe('send_mail executor', () => {
104
+ beforeEach(async () => {
105
+ const { sendAutomationMail } = await import('../../email')
106
+ vi.mocked(sendAutomationMail).mockClear()
107
+ })
108
+
109
+ it('interpolates to/subject/body and calls sendAutomationMail', async () => {
110
+ const { sendAutomationMail } = await import('../../email')
111
+ const ctx = makeCtx({ entry: { id: '1', email: 'user@example.com', title: 'Hello' } })
112
+
113
+ await executeAction(
114
+ {
115
+ type: 'send_mail',
116
+ to: '{{email}}',
117
+ subject_template: 'Re: {{title}}',
118
+ body_template: 'Your entry {{title}} was created.',
119
+ },
120
+ ctx,
121
+ )
122
+
123
+ expect(sendAutomationMail).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ to: 'user@example.com',
126
+ subject: 'Re: Hello',
127
+ body: 'Your entry Hello was created.',
128
+ }),
129
+ )
130
+ })
131
+
132
+ it('substitutes default value [missing] and logs a warning when concrete data is absent', async () => {
133
+ const { sendAutomationMail } = await import('../../email')
134
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
135
+ const ctx = makeCtx({ entry: { id: '1' } })
136
+
137
+ await executeAction(
138
+ {
139
+ type: 'send_mail',
140
+ to: '{{email}}',
141
+ subject_template: 'Re: {{title}}',
142
+ body_template: 'Content: {{desc}}',
143
+ },
144
+ ctx,
145
+ )
146
+
147
+ expect(sendAutomationMail).toHaveBeenCalledWith(
148
+ expect.objectContaining({
149
+ to: '[missing]',
150
+ subject: 'Re: [missing]',
151
+ body: 'Content: [missing]',
152
+ }),
153
+ )
154
+ expect(warnSpy).toHaveBeenCalledWith(
155
+ expect.stringContaining('missing concrete data'),
156
+ ['email', 'title', 'desc'],
157
+ )
158
+ })
159
+
160
+ it('skips execution gracefully and logs an error without calling sendAutomationMail if apiKey is missing', async () => {
161
+ const { sendAutomationMail } = await import('../../email')
162
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
163
+ const ctx = makeCtx({ env: {} }) // missing email/resend api keys
164
+
165
+ await executeAction(
166
+ {
167
+ type: 'send_mail',
168
+ to: 'user@beech.io',
169
+ subject_template: 'Hello',
170
+ body_template: 'World',
171
+ },
172
+ ctx,
173
+ )
174
+
175
+ expect(sendAutomationMail).not.toHaveBeenCalled()
176
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Execution skipped'))
177
+ })
178
+
179
+ it('logs error and rethrows when sendAutomationMail fails', async () => {
180
+ const { sendAutomationMail } = await import('../../email')
181
+ vi.mocked(sendAutomationMail).mockRejectedValueOnce(new Error('provider error'))
182
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
183
+ const ctx = makeCtx()
184
+
185
+ await expect(
186
+ executeAction(
187
+ {
188
+ type: 'send_mail',
189
+ to: 'test@beech.io',
190
+ subject_template: 'Hello',
191
+ body_template: 'World',
192
+ },
193
+ ctx,
194
+ ),
195
+ ).rejects.toThrow('provider error')
196
+
197
+ expect(errSpy).toHaveBeenCalledWith(
198
+ expect.stringContaining('failed to send automation email'),
199
+ expect.any(Error),
200
+ )
201
+ })
202
+ })
203
+
204
+ // ── edit_field ────────────────────────────────────────────────────────────────
205
+
206
+ describe('edit_field executor', () => {
207
+ it('calls repository.update with interpolated string value', async () => {
208
+ const ctx = makeCtx({ entry: { id: 'entry-1', title: 'Old Title' } })
209
+ await executeAction({ type: 'edit_field', field: 'summary', value: 'Based on {{title}}' }, ctx)
210
+ expect(ctx.repository.update).toHaveBeenCalledWith(ctx.seed, 'entry-1', { summary: 'Based on Old Title' })
211
+ })
212
+
213
+ it('calls repository.update with raw non-string value', async () => {
214
+ const ctx = makeCtx({ entry: { id: 'entry-1', title: 'T' } })
215
+ await executeAction({ type: 'edit_field', field: 'count', value: 42 }, ctx)
216
+ expect(ctx.repository.update).toHaveBeenCalledWith(ctx.seed, 'entry-1', { count: 42 })
217
+ })
218
+
219
+ it('throws when entry.id is missing', async () => {
220
+ const ctx = makeCtx({ entry: { title: 'No ID' } })
221
+ await expect(executeAction({ type: 'edit_field', field: 'x', value: 'y' }, ctx)).rejects.toThrow('entry.id missing')
222
+ })
223
+ })
224
+
225
+ // ── create_entry ──────────────────────────────────────────────────────────────
226
+
227
+ describe('create_entry executor', () => {
228
+ it('creates a new entry with field_map values from trigger entry', async () => {
229
+ const ctx = makeCtx({ entry: { id: 'e1', author: 'Alice' } })
230
+ await executeAction(
231
+ { type: 'create_entry', seed_slug: 'comments', field_map: { writer: 'author', fixed: 'literal-value' } },
232
+ ctx,
233
+ )
234
+ expect(ctx.idGenerator.uuid).toHaveBeenCalled()
235
+ expect(ctx.repository.create).toHaveBeenCalledWith(
236
+ expect.anything(),
237
+ 'new-id-123',
238
+ 'new-id-123',
239
+ 'draft',
240
+ { writer: 'Alice', fixed: 'literal-value' },
241
+ )
242
+ })
243
+
244
+ it('uses literal string when source field is not in entry', async () => {
245
+ const ctx = makeCtx({ entry: { id: 'e1' } })
246
+ await executeAction(
247
+ { type: 'create_entry', seed_slug: 'comments', field_map: { tag: 'missing-field' } },
248
+ ctx,
249
+ )
250
+ expect(ctx.repository.create).toHaveBeenCalledWith(
251
+ expect.anything(), 'new-id-123', 'new-id-123', 'draft',
252
+ { tag: 'missing-field' },
253
+ )
254
+ })
255
+
256
+ it('throws when seed not found', async () => {
257
+ const ctx = makeCtx({ getSeed: vi.fn().mockReturnValue(null) })
258
+ await expect(
259
+ executeAction({ type: 'create_entry', seed_slug: 'unknown', field_map: {} }, ctx),
260
+ ).rejects.toThrow('unknown seed unknown')
261
+ })
262
+
263
+ it('throws error for unknown action type to cover exhaustive default check', async () => {
264
+ const ctx = makeCtx()
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ await expect(executeAction({ type: 'unknown_custom_action' } as any, ctx)).rejects.toThrow('unknown action type')
267
+ })
268
+ })