@beechcms/api 0.4.0-preview.11 → 0.4.0-preview.13

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