@beechcms/api 0.4.0-preview.9 → 0.4.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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
package/src/factory.ts CHANGED
@@ -1,303 +1,339 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import { cors } from 'hono/cors'
4
- import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
5
- import type { Seed } from '@beechcms/core'
6
- import type { Env, Variables } from './types'
7
-
8
- // Imports delle rotte e middleware
9
- import { AUTH_ERRORS } from './auth/constants'
10
- import {
11
- parseLoginBody,
12
- validateLoginInput,
13
- findUserByEmail,
14
- verifyPassword,
15
- DUMMY_PASSWORD_HASH,
16
- } from './auth/login'
17
- import {
18
- generateRefreshToken,
19
- saveRefreshToken,
20
- generateAccessToken,
21
- validateRefreshToken,
22
- revokeRefreshToken,
23
- } from './auth/refresh'
24
- import { authMiddleware } from './middleware'
25
- import { contentRoutes } from './content'
26
- import { widgetApp } from './widget'
27
- import { rotateFieldApp } from './features/rotate-field'
28
- import { passwordResetApp } from './features/password-reset'
29
- import { setupApp } from './features/setup'
30
- import { draftApp } from './features/draft'
31
- import { settingsApp } from './features/settings/settings.handler'
32
- import { schemaApp } from './features/schema/schema.handler'
33
- import { notificationsApp } from './features/notifications'
34
- import { statsApp } from './features/stats'
35
- import { uploadRoutes, serveMediaHandler } from './upload'
36
- import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
37
- import { searchRouter } from "./search"
38
-
39
- export interface BeechConfig {
40
- seeds: Seed[] | Record<string, Seed>
41
- }
42
-
43
- // --- Costanti e helper ---
44
- const REFRESH_TOKEN_EXPIRY_DAYS = 7
45
- const SECONDS_PER_DAY = 24 * 60 * 60
46
-
47
- function isRequestSecure(url: string): boolean {
48
- return new URL(url).protocol === 'https:'
49
- }
50
-
51
- function getClientIp(headers: Headers): string {
52
- return headers.get('cf-connecting-ip') ?? 'unknown'
53
- }
54
-
55
- function getRefreshTokenCookieOptions(secure: boolean) {
56
- return {
57
- httpOnly: true,
58
- secure,
59
- sameSite: 'Strict' as const,
60
- maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
61
- path: '/auth',
62
- }
63
- }
64
-
65
- function getRefreshTokenDeleteCookieOptions(secure: boolean) {
66
- return {
67
- httpOnly: true,
68
- secure,
69
- sameSite: 'Strict' as const,
70
- path: '/auth',
71
- }
72
- }
73
-
74
- function handleAuthError(c: any, err: unknown, operationName: string): Response {
75
- if (c.env.ENV !== 'production') {
76
- console.error(`${operationName} error:`, err)
77
- }
78
- return c.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
79
- }
80
-
81
- function extractPublicSeed(path: string): string {
82
- const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
83
- return match ? match[1] : ''
84
- }
85
-
86
- /**
87
- * Builds a fully configured Hono app with the given seeds injected into context.
88
- * This is the main entry point for a BeechCMS project.
89
- */
90
- export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
91
- const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
92
- const registry: Record<string, Seed> = Object.fromEntries(seedsArray.map(s => [s.slug, s]))
93
- const getSeedFn = (slug: string): Seed | null => registry[slug] ?? null
94
-
95
- const app = new Hono<{ Bindings: Env; Variables: Variables }>()
96
-
97
- // 1. Core Middleware (Seeds, CORS, Security)
98
- app.use('*', async (c, next) => {
99
- c.set('getSeed', getSeedFn)
100
- c.set('seedRegistry', registry)
101
- await next()
102
- })
103
-
104
- app.use('*', async (c, next) => {
105
- const origins = (c.env.CORS_ORIGINS ?? 'http://localhost:5173')
106
- .split(',')
107
- .map((o) => o.trim())
108
- .filter(Boolean)
109
- return cors({
110
- origin: (origin) => {
111
- if (!origin) return origins[0] ?? null
112
- return origins.includes(origin) ? origin : null
113
- },
114
- allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
115
- allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
116
- credentials: true,
117
- })(c, next)
118
- })
119
-
120
- app.use('*', async (c, next) => {
121
- await next()
122
- if (c.req.path.startsWith('/admin')) return
123
- c.header('X-Frame-Options', 'DENY')
124
- c.header('X-Content-Type-Options', 'nosniff')
125
- c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
126
- c.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
127
- c.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
128
- })
129
-
130
- // 2. Analytics Middleware
131
- app.use('/api/*', async (c, next) => {
132
- await next()
133
- if (c.req.method !== 'OPTIONS' && c.res.status >= 200 && c.res.status < 300) {
134
- const db = c.env.DB
135
- let executionCtx: any
136
- try { executionCtx = c.executionCtx } catch {}
137
-
138
- if (db && executionCtx) {
139
- const seed = extractPublicSeed(c.req.path)
140
- executionCtx.waitUntil((async () => {
141
- try {
142
- const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
143
- await db.prepare(
144
- `INSERT INTO analytics (day_ts, metric, seed, value)
145
- VALUES (?, 'requests', ?, 1)
146
- ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
147
- ).bind(today, seed).run()
148
- } catch (err) {
149
- console.error('Analytics middleware error:', err)
150
- }
151
- })())
152
- }
153
- }
154
- })
155
-
156
- // 3. Auth Routes
157
- app.post('/auth/login', async (c) => {
158
- try {
159
- let body: any
160
- try { body = await c.req.json() } catch { return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
161
- const credentials = parseLoginBody(body)
162
- if (!credentials) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
163
- const { email, password } = credentials
164
- if (!validateLoginInput(email, password)) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
165
-
166
- const loginLimiter = c.env.LOGIN_RATE_LIMITER
167
- if (loginLimiter) {
168
- const clientIp = getClientIp(c.req.raw.headers)
169
- const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
170
- if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
171
- }
172
-
173
- const { DB, JWT_SECRET } = c.env
174
- const user = await findUserByEmail(DB, email)
175
- const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
176
- const isValid = await verifyPassword(password, hashToCompare)
177
-
178
- if (!user || !isValid) return c.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
179
-
180
- const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
181
- const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
182
- issuer: c.env.JWT_ISSUER,
183
- audience: c.env.JWT_AUDIENCE,
184
- }, userProfile?.name ?? undefined)
185
- const refreshToken = generateRefreshToken()
186
-
187
- await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
188
- setCookie(c, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
189
- return c.json({ token: accessToken, expiresIn: '15m' }, 200)
190
- } catch (err) {
191
- return handleAuthError(c, err, 'Login')
192
- }
193
- })
194
-
195
- app.post('/auth/refresh', async (c) => {
196
- try {
197
- const refreshLimiter = c.env.REFRESH_RATE_LIMITER
198
- if (refreshLimiter) {
199
- const clientIp = getClientIp(c.req.raw.headers)
200
- const { success } = await refreshLimiter.limit({ key: clientIp })
201
- if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
202
- }
203
-
204
- const refreshToken = getCookie(c, 'refresh_token')
205
- if (!refreshToken) return c.json({ error: 'Refresh token missing' }, 401)
206
-
207
- const { DB, JWT_SECRET } = c.env
208
- const validation = await validateRefreshToken(DB, refreshToken)
209
- if (!validation.valid || !validation.userId) return c.json({ error: 'Invalid refresh token' }, 401)
210
-
211
- 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 }>()
212
- if (!user) return c.json({ error: 'User not found' }, 401)
213
-
214
- const revoked = await revokeRefreshToken(DB, refreshToken)
215
- if (!revoked) return c.json({ error: 'Invalid refresh token' }, 401)
216
-
217
- const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
218
- issuer: c.env.JWT_ISSUER,
219
- audience: c.env.JWT_AUDIENCE,
220
- }, user.name ?? undefined)
221
- const newRefreshToken = generateRefreshToken()
222
-
223
- await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
224
- setCookie(c, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
225
- return c.json({ token: newAccessToken, expiresIn: '15m' }, 200)
226
- } catch (err) {
227
- return handleAuthError(c, err, 'Refresh')
228
- }
229
- })
230
-
231
- app.post('/auth/logout', async (c) => {
232
- try {
233
- const refreshToken = getCookie(c, 'refresh_token')
234
- if (refreshToken) await revokeRefreshToken(c.env.DB, refreshToken)
235
- deleteCookie(c, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(c.req.url)))
236
- return c.json({ message: 'Logged out' }, 200)
237
- } catch (err) {
238
- return handleAuthError(c, err, 'Logout')
239
- }
240
- })
241
-
242
- // 4. Setup & Password Reset
243
- app.route('/', setupApp)
244
- app.route('/', passwordResetApp)
245
-
246
- // 5. Protected CMS API
247
- const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
248
- apiProtected.use('*', async (c, next) => {
249
- await authMiddleware(c.env.JWT_SECRET, {
250
- issuer: c.env.JWT_ISSUER,
251
- audience: c.env.JWT_AUDIENCE,
252
- })(c, next)
253
- })
254
-
255
- apiProtected.route('/settings', settingsApp)
256
- apiProtected.route('/schema', schemaApp)
257
- apiProtected.route('/content', notificationsApp)
258
- apiProtected.route('/content', statsApp)
259
- apiProtected.route('/content', rotateFieldApp)
260
- apiProtected.route('/content', draftApp)
261
- apiProtected.route('/content', contentRoutes)
262
- apiProtected.route('/widget', widgetApp)
263
-
264
- app.route('/api', apiProtected)
265
- app.route('/api/search', searchRouter)
266
- app.route('/api', uploadRoutes)
267
- app.get('/api/media/:key', (c) => serveMediaHandler(c))
268
-
269
- // 6. Public API
270
- const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
271
- apiPublic.use('*', publicRateLimitMiddleware())
272
- apiPublic.use('*', apiKeyMiddleware())
273
- apiPublic.route('/', publicRoutes)
274
- app.route('/api/v1/public', apiPublic)
275
-
276
- // 7. Dashboard SPA — serve static assets from Workers Assets binding
277
- app.get('/admin', (c) => c.redirect('/admin/', 301))
278
- app.get('/admin/*', async (c) => {
279
- if (!c.env.ASSETS) {
280
- return c.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
281
- }
282
- const url = new URL(c.req.url)
283
- const originalPath = url.pathname
284
- url.pathname = originalPath.replace(/^\/admin/, '') || '/'
285
-
286
- let assetResponse = await c.env.ASSETS.fetch(new Request(url.toString(), c.req.raw))
287
- if (assetResponse.status === 404) {
288
- // SPA fallback: any unmatched /admin/* route serves index.html
289
- assetResponse = await c.env.ASSETS.fetch(new Request(new URL('/index.html', c.req.url)))
290
- }
291
- // ASSETS returns an immutable Response — wrap it to inject security headers
292
- const headers = new Headers(assetResponse.headers)
293
- headers.set('Content-Security-Policy',
294
- "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self'; frame-ancestors 'none'"
295
- )
296
- headers.set('X-Frame-Options', 'DENY')
297
- headers.set('X-Content-Type-Options', 'nosniff')
298
- headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
299
- return new Response(assetResponse.body, { status: assetResponse.status, headers })
300
- })
301
-
302
- return app
303
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import { cors } from 'hono/cors'
4
+ import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
5
+ import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
6
+ import { sha256hex, SystemClock, SystemIdGenerator, SeedRegistry } from '@beechcms/core'
7
+ import type { Env, Variables } from './types'
8
+ import { getClientIp } from './shared/request-utils'
9
+
10
+ // Imports delle rotte e middleware
11
+ import { AUTH_ERRORS } from './auth/constants'
12
+ import {
13
+ parseLoginBody,
14
+ validateLoginInput,
15
+ verifyPassword,
16
+ DUMMY_PASSWORD_HASH,
17
+ } from './auth/login'
18
+ import { generateRefreshToken } from './auth/refresh'
19
+ import { authMiddleware } from './middleware'
20
+ import contentFeature from './features/content'
21
+ import { widgetApp } from './widget'
22
+ import { rotateFieldApp } from './features/rotate-field'
23
+ import { passwordResetApp } from './features/password-reset'
24
+ import { setupApp } from './features/setup'
25
+ import { draftApp } from './features/draft'
26
+ import { settingsApp } from './features/settings/settings.handler'
27
+ import { schemaApp } from './features/schema/schema.handler'
28
+ import { notificationsApp } from './features/notifications'
29
+ import { statsApp } from './features/stats'
30
+ import { uploadRoutes, serveMediaHandler } from './upload'
31
+ import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
32
+ import { searchRouter } from "./search"
33
+ import { repositoryMiddleware } from './middleware/repository.middleware'
34
+ import { storageMiddleware } from './middleware/storage.middleware'
35
+ import { authProvidersMiddleware } from './middleware/auth-providers.middleware'
36
+ import { rateLimiterMiddleware } from './middleware/rate-limit.middleware'
37
+ import { observabilityMiddleware } from './middleware/observability.middleware'
38
+
39
+ export interface BeechConfig {
40
+ seeds: Seed[] | Record<string, Seed>
41
+ repository?: ContentRepository
42
+ idempotencyRepository?: IdempotencyRepository
43
+ bucket?: BeechBucket
44
+ mediaRepository?: MediaRepository
45
+ systemStatsRepository?: SystemStatsRepository
46
+ }
47
+
48
+ // --- Costanti e helper ---
49
+ const REFRESH_TOKEN_EXPIRY_DAYS = 7
50
+ const SECONDS_PER_DAY = 24 * 60 * 60
51
+
52
+ function isRequestSecure(url: string): boolean {
53
+ return new URL(url).protocol === 'https:'
54
+ }
55
+
56
+ function getRefreshTokenCookieOptions(secure: boolean) {
57
+ return {
58
+ httpOnly: true,
59
+ secure,
60
+ sameSite: 'Strict' as const,
61
+ maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
62
+ path: '/auth',
63
+ }
64
+ }
65
+
66
+ function getRefreshTokenDeleteCookieOptions(secure: boolean) {
67
+ return {
68
+ httpOnly: true,
69
+ secure,
70
+ sameSite: 'Strict' as const,
71
+ path: '/auth',
72
+ }
73
+ }
74
+
75
+ function handleAuthError(context: any, error: unknown, operationName: string): Response {
76
+ if (context.env.ENV !== 'production') {
77
+ console.error(`${operationName} error:`, error)
78
+ }
79
+ return context.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
80
+ }
81
+
82
+ function extractPublicSeed(path: string): string {
83
+ const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
84
+ return match ? match[1] : ''
85
+ }
86
+
87
+ /**
88
+ * Builds a fully configured Hono app with the given seeds injected into context.
89
+ * This is the main entry point for a BeechCMS project.
90
+ */
91
+ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
92
+ const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
93
+ // Filter out any invalid objects that might have leaked into the registry (e.g. module exports)
94
+ const validSeeds = seedsArray.filter(s => s && typeof s === 'object' && 'slug' in s)
95
+ const seedRegistry = new SeedRegistry(validSeeds)
96
+
97
+ const app = new Hono<{ Bindings: Env; Variables: Variables }>()
98
+
99
+ // 1. Core Middleware (Seeds, CORS, Security)
100
+ app.use('*', async (context, next) => {
101
+ context.set('getSeed', (slug: string) => seedRegistry.get(slug))
102
+ context.set('seedRegistry', seedRegistry)
103
+ await next()
104
+ })
105
+
106
+ // 1.1 Repository Injection
107
+ app.use('*', repositoryMiddleware({
108
+ repository: config.repository,
109
+ idempotencyRepository: config.idempotencyRepository,
110
+ mediaRepository: config.mediaRepository,
111
+ systemStatsRepository: config.systemStatsRepository,
112
+ }))
113
+
114
+ app.use('*', storageMiddleware({
115
+ bucket: config.bucket,
116
+ }))
117
+
118
+ app.use('*', authProvidersMiddleware())
119
+ app.use('*', rateLimiterMiddleware())
120
+ app.use('*', observabilityMiddleware())
121
+
122
+ app.use('*', async (context, next) => {
123
+ const isDev = context.env.ENV !== 'production'
124
+
125
+ return cors({
126
+ origin: (origin) => {
127
+ if (!origin) return origin ?? ''
128
+
129
+ // In dev, allow all localhost/127.0.0.1 origins regardless of port
130
+ if (isDev) {
131
+ try {
132
+ const { hostname } = new URL(origin)
133
+ if (hostname === 'localhost' || hostname === '127.0.0.1') return origin
134
+ } catch {}
135
+ }
136
+
137
+ const origins = (context.env.CORS_ORIGINS ?? 'http://localhost:5173')
138
+ .split(',')
139
+ .map((o) => o.trim())
140
+ .filter(Boolean)
141
+
142
+ if (origins.includes(origin)) return origin
143
+
144
+ // Allow same-origin requests
145
+ try {
146
+ const originUrl = new URL(origin)
147
+ const requestUrl = new URL(context.req.url)
148
+ if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
149
+ return origin
150
+ }
151
+ } catch {}
152
+
153
+ return null
154
+ },
155
+ allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
156
+ allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
157
+ credentials: true,
158
+ })(context, next)
159
+ })
160
+
161
+ app.use('*', async (context, next) => {
162
+ await next()
163
+ if (context.req.path.startsWith('/admin')) return
164
+ context.header('X-Frame-Options', 'DENY')
165
+ context.header('X-Content-Type-Options', 'nosniff')
166
+ context.header('Referrer-Policy', 'strict-origin-when-cross-origin')
167
+ context.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
168
+ context.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
169
+ })
170
+
171
+ // 2. Analytics Middleware
172
+ app.use('/api/*', async (context, next) => {
173
+ await next()
174
+ if (context.req.method === 'OPTIONS') return
175
+ if (context.res.status < 200 || context.res.status >= 300) return
176
+
177
+ let executionCtx: any
178
+ try { executionCtx = context.executionCtx } catch {}
179
+ if (!executionCtx) return
180
+
181
+ const analyticsRepository = context.get('analyticsRepository')
182
+ if (!analyticsRepository) return
183
+
184
+ const seedSlug = extractPublicSeed(context.req.path)
185
+
186
+ executionCtx.waitUntil(
187
+ analyticsRepository.recordRequest(seedSlug).catch((error: unknown) => {
188
+ console.error('Analytics middleware error:', error)
189
+ })
190
+ )
191
+ })
192
+
193
+ // 3. Auth Routes
194
+ app.post('/auth/login', async (context) => {
195
+ try {
196
+ let body: any
197
+ try { body = await context.req.json() } catch { return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
198
+ const credentials = parseLoginBody(body)
199
+ if (!credentials) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
200
+ const { email, password } = credentials
201
+ if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
202
+
203
+ const clientIp = getClientIp(context.req)
204
+ const loginRateLimit = await context.get('rateLimiters').getLimiter('login').checkLimit(clientIp)
205
+ if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
206
+
207
+ const user = await context.get('userRepository').findByEmail(email)
208
+ const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH
209
+ const isValid = await verifyPassword(password, hashToCompare, context.get('hashProvider'))
210
+
211
+ if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
212
+
213
+ const accessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
214
+ const refreshToken = generateRefreshToken()
215
+ const refreshTokenHash = await sha256hex(refreshToken)
216
+ const nowSeconds = SystemClock.nowSeconds()
217
+
218
+ await context.get('sessionRepository').saveRefreshToken({
219
+ id: SystemIdGenerator.uuid(),
220
+ userId: user.id,
221
+ tokenHash: refreshTokenHash,
222
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
223
+ })
224
+ setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
225
+ return context.json({ token: accessToken, expiresIn: '15m' }, 200)
226
+ } catch (error) {
227
+ return handleAuthError(context, error, 'Login')
228
+ }
229
+ })
230
+
231
+ app.post('/auth/refresh', async (context) => {
232
+ try {
233
+ const refreshClientIp = getClientIp(context.req)
234
+ const refreshRateLimit = await context.get('rateLimiters').getLimiter('tokenRefresh').checkLimit(refreshClientIp)
235
+ if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
236
+
237
+ const refreshToken = getCookie(context, 'refresh_token')
238
+ if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
239
+
240
+ const nowSeconds = SystemClock.nowSeconds()
241
+ const tokenHash = await sha256hex(refreshToken)
242
+ const activeSession = await context.get('sessionRepository').findActiveByHash(tokenHash, nowSeconds)
243
+ if (!activeSession) return context.json({ error: 'Invalid refresh token' }, 401)
244
+
245
+ const user = await context.get('userRepository').findById(activeSession.userId)
246
+ if (!user) return context.json({ error: 'User not found' }, 401)
247
+
248
+ const revoked = await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
249
+ if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
250
+
251
+ const newAccessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
252
+ const newRefreshToken = generateRefreshToken()
253
+ const newRefreshTokenHash = await sha256hex(newRefreshToken)
254
+
255
+ await context.get('sessionRepository').saveRefreshToken({
256
+ id: SystemIdGenerator.uuid(),
257
+ userId: user.id,
258
+ tokenHash: newRefreshTokenHash,
259
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
260
+ })
261
+ setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
262
+ return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
263
+ } catch (error) {
264
+ return handleAuthError(context, error, 'Refresh')
265
+ }
266
+ })
267
+
268
+ app.post('/auth/logout', async (context) => {
269
+ try {
270
+ const refreshToken = getCookie(context, 'refresh_token')
271
+ if (refreshToken) {
272
+ const nowSeconds = SystemClock.nowSeconds()
273
+ const tokenHash = await sha256hex(refreshToken)
274
+ await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
275
+ }
276
+ deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
277
+ return context.json({ message: 'Logged out' }, 200)
278
+ } catch (error) {
279
+ return handleAuthError(context, error, 'Logout')
280
+ }
281
+ })
282
+
283
+ // 4. Setup & Password Reset
284
+ app.route('/', setupApp)
285
+ app.route('/', passwordResetApp)
286
+
287
+ // 5. Protected CMS API
288
+ const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
289
+ apiProtected.use('*', authMiddleware())
290
+
291
+ apiProtected.route('/settings', settingsApp)
292
+ apiProtected.route('/schema', schemaApp)
293
+ apiProtected.route('/content', notificationsApp)
294
+ apiProtected.route('/content', statsApp)
295
+ apiProtected.route('/content', rotateFieldApp)
296
+ apiProtected.route('/content', draftApp)
297
+ apiProtected.route('/content', contentFeature)
298
+ apiProtected.route('/widget', widgetApp)
299
+ apiProtected.route('/', uploadRoutes)
300
+
301
+ // 6. Public API (must be registered before apiProtected to avoid auth middleware interception)
302
+ const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
303
+ apiPublic.use('*', publicRateLimitMiddleware())
304
+ apiPublic.use('*', apiKeyMiddleware())
305
+ apiPublic.route('/', publicRoutes)
306
+ app.route('/api/v1/public', apiPublic)
307
+
308
+ app.get('/api/media/:key', (context) => serveMediaHandler(context))
309
+ app.route('/api', apiProtected)
310
+ app.route('/api/search', searchRouter)
311
+
312
+ // 7. Dashboard SPA — serve static assets from Workers Assets binding
313
+ app.get('/admin', (context) => context.redirect('/admin/', 301))
314
+ app.get('/admin/*', async (context) => {
315
+ if (!context.env.ASSETS) {
316
+ return context.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
317
+ }
318
+ const url = new URL(context.req.url)
319
+ const originalPath = url.pathname
320
+ url.pathname = originalPath.replace(/^\/admin/, '') || '/'
321
+
322
+ let assetResponse = await context.env.ASSETS.fetch(new Request(url.toString(), context.req.raw))
323
+ if (assetResponse.status === 404) {
324
+ // SPA fallback: any unmatched /admin/* route serves index.html
325
+ assetResponse = await context.env.ASSETS.fetch(new Request(new URL('/index.html', context.req.url)))
326
+ }
327
+ // ASSETS returns an immutable Response — wrap it to inject security headers
328
+ const headers = new Headers(assetResponse.headers)
329
+ headers.set('Content-Security-Policy',
330
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self'; frame-ancestors 'none'"
331
+ )
332
+ headers.set('X-Frame-Options', 'DENY')
333
+ headers.set('X-Content-Type-Options', 'nosniff')
334
+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
335
+ return new Response(assetResponse.body, { status: assetResponse.status, headers })
336
+ })
337
+
338
+ return app
339
+ }