@beechcms/api 0.4.0-preview.9 → 0.4.0

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 (89) 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 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  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 -249
  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 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  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 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  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 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  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 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
package/src/factory.ts CHANGED
@@ -1,303 +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 } 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 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
+ }