@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.
- package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +80 -80
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +22 -10
- package/src/features/content/handlers/delete.ts +21 -10
- package/src/features/content/handlers/update.ts +21 -9
- package/src/features/draft/draft.handler.ts +51 -153
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +64 -176
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/index.ts +40 -8
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +72 -5
- package/src/middleware.ts +15 -35
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +110 -166
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +59 -216
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- package/src/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/automations.repository.d1.ts +146 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.test.ts +76 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/execution-context-scheduler.ts +9 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage-utils.ts +3 -3
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +24 -3
- package/src/upload.ts +17 -9
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-CewtCjom.css +0 -1
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- 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
|
|
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',
|
|
109
|
-
context.set('seedRegistry',
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
218
|
-
const
|
|
219
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
252
|
-
const
|
|
253
|
-
|
|
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
|
|
256
|
-
if (!user)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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)
|
|
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('*',
|
|
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
|
+
})
|