@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
package/src/factory.ts CHANGED
@@ -1,368 +1,370 @@
1
- // SPDX-License-Identifier: BUSL-1.1
2
- // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
- // See LICENSE in the repository root for license terms.
4
-
5
- /// <reference types="@cloudflare/workers-types" />
6
- import { Hono } from 'hono'
7
- import { cors } from 'hono/cors'
8
- import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
9
- import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
10
- import { sha256hex, SystemClock, SystemIdGenerator } from '@beechcms/core'
11
- import type { Env, Variables } from './types'
12
- import { getClientIp } from './shared/request-utils'
13
-
14
- // Imports delle rotte e middleware
15
- import { AUTH_ERRORS } from './auth/constants'
16
- import {
17
- parseLoginBody,
18
- validateLoginInput,
19
- verifyPassword,
20
- DUMMY_PASSWORD_HASH,
21
- } from './auth/login'
22
- import { generateRefreshToken } from './auth/refresh'
23
- import { authMiddleware } from './middleware'
24
- import contentFeature from './features/content'
25
- import { widgetApp } from './widget'
26
- import { rotateFieldApp } from './features/rotate-field'
27
- import { passwordResetApp } from './features/password-reset'
28
- import { setupApp } from './features/setup'
29
- import { draftApp } from './features/draft'
30
- import { settingsApp } from './features/settings/settings.handler'
31
- import { schemaApp } from './features/schema/schema.handler'
32
- import { seedsApp } from './features/seeds'
33
- import { notificationsApp } from './features/notifications'
34
- import { automationsApp } from './features/automations'
35
- import { statsApp } from './features/stats'
36
- import { backrefsApp } from './features/backrefs'
37
- import { webhooksApp } from './features/webhooks'
38
- import { uploadRoutes, serveMediaHandler } from './upload'
39
- import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
40
- import { searchRouter } from "./search"
41
- import type { ISeedRepository } from '@beechcms/core'
42
- import { repositoryMiddleware } from './middleware/repository.middleware'
43
- import { seedRegistryMiddleware } from './middleware/seed-registry.middleware'
44
- import { storageMiddleware } from './middleware/storage.middleware'
45
- import { InMemorySeedRepository } from './shared/in-memory-seed.repository'
46
- import { authProvidersMiddleware } from './middleware/auth-providers.middleware'
47
- import { rateLimiterMiddleware } from './middleware/rate-limit.middleware'
48
- import { observabilityMiddleware } from './middleware/observability.middleware'
49
-
50
- export interface BeechConfig {
51
- seeds: Seed[] | Record<string, Seed>
52
- repository?: ContentRepository
53
- idempotencyRepository?: IdempotencyRepository
54
- bucket?: BeechBucket
55
- mediaRepository?: MediaRepository
56
- systemStatsRepository?: SystemStatsRepository
57
- // Optional override: inject a custom ISeedRepository (e.g. InMemorySeedRepository for tests).
58
- // When omitted and seeds is non-empty, an InMemorySeedRepository is created automatically
59
- // for backward compatibility.
60
- seedRepository?: ISeedRepository
61
- }
62
-
63
- // --- Costanti e helper ---
64
- const REFRESH_TOKEN_EXPIRY_DAYS = 7
65
- const SECONDS_PER_DAY = 24 * 60 * 60
66
-
67
- function isRequestSecure(url: string): boolean {
68
- return new URL(url).protocol === 'https:'
69
- }
70
-
71
- function getRefreshTokenCookieOptions(secure: boolean) {
72
- return {
73
- httpOnly: true,
74
- secure,
75
- sameSite: 'Strict' as const,
76
- maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
77
- path: '/auth',
78
- }
79
- }
80
-
81
- function getRefreshTokenDeleteCookieOptions(secure: boolean) {
82
- return {
83
- httpOnly: true,
84
- secure,
85
- sameSite: 'Strict' as const,
86
- path: '/auth',
87
- }
88
- }
89
-
90
- function handleAuthError(context: any, error: unknown, operationName: string): Response {
91
- if (context.env.ENV !== 'production') {
92
- console.error(`${operationName} error:`, error)
93
- }
94
- return context.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
95
- }
96
-
97
- function extractPublicSeed(path: string): string {
98
- const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
99
- return match ? match[1] : ''
100
- }
101
-
102
- /**
103
- * Builds a fully configured Hono app. Seeds are hydrated from D1 at runtime via
104
- * seedRegistryMiddleware; config.seeds is retained for back-compat but no longer
105
- * drives request handling.
106
- */
107
- export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
108
- // Back-compat shim: when callers (e.g. existing tests) pass seeds without a custom
109
- // seedRepository, wrap them in an InMemorySeedRepository so the middleware can serve
110
- // them without touching D1. Production index.ts passes seeds:[] so D1SeedRepository
111
- // is used as the live path.
112
- const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
113
- const seedRepository = config.seedRepository
114
- ?? (seedsArray.length > 0 ? new InMemorySeedRepository(seedsArray) : undefined)
115
-
116
- const app = new Hono<{ Bindings: Env; Variables: Variables }>()
117
-
118
- // 1. Repository Injection (must be first — seedRegistryMiddleware depends on seedRepository)
119
- app.use('*', repositoryMiddleware({
120
- repository: config.repository,
121
- idempotencyRepository: config.idempotencyRepository,
122
- mediaRepository: config.mediaRepository,
123
- systemStatsRepository: config.systemStatsRepository,
124
- seedRepository,
125
- }))
126
-
127
- // 2. Seed Registry Hydration (D1-backed, version-token-cached per isolate)
128
- app.use('*', seedRegistryMiddleware())
129
-
130
- // 3. Storage / Auth / Rate Limiting / Observability
131
- app.use('*', storageMiddleware({
132
- bucket: config.bucket,
133
- }))
134
-
135
- app.use('*', authProvidersMiddleware())
136
- app.use('*', rateLimiterMiddleware())
137
- app.use('*', observabilityMiddleware())
138
-
139
- app.use('*', async (context, next) => {
140
- const isDev = context.env.ENV !== 'production'
141
-
142
- return cors({
143
- origin: (origin) => {
144
- if (!origin) return origin ?? ''
145
-
146
- // In dev, allow all localhost/127.0.0.1 origins regardless of port
147
- if (isDev) {
148
- try {
149
- const { hostname } = new URL(origin)
150
- if (hostname === 'localhost' || hostname === '127.0.0.1') return origin
151
- } catch {}
152
- }
153
-
154
- const origins = (context.env.CORS_ORIGINS ?? 'http://localhost:5173')
155
- .split(',')
156
- .map((o) => o.trim())
157
- .filter(Boolean)
158
-
159
- if (origins.includes(origin)) return origin
160
-
161
- // Allow same-origin requests
162
- try {
163
- const originUrl = new URL(origin)
164
- const requestUrl = new URL(context.req.url)
165
- if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
166
- return origin
167
- }
168
- } catch {}
169
-
170
- return null
171
- },
172
- allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
173
- allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
174
- credentials: true,
175
- })(context, next)
176
- })
177
-
178
- app.use('*', async (context, next) => {
179
- await next()
180
- if (context.req.path.startsWith('/admin')) return
181
- context.header('X-Frame-Options', 'DENY')
182
- context.header('X-Content-Type-Options', 'nosniff')
183
- context.header('Referrer-Policy', 'strict-origin-when-cross-origin')
184
- context.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
185
- context.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
186
- })
187
-
188
- // 2. Analytics Middleware
189
- app.use('/api/*', async (context, next) => {
190
- await next()
191
- if (context.req.method === 'OPTIONS') return
192
- if (context.res.status < 200 || context.res.status >= 300) return
193
-
194
- let executionCtx: any
195
- try { executionCtx = context.executionCtx } catch {}
196
- if (!executionCtx) return
197
-
198
- const analyticsRepository = context.get('analyticsRepository')
199
- if (!analyticsRepository) return
200
-
201
- const seedSlug = extractPublicSeed(context.req.path)
202
-
203
- executionCtx.waitUntil(
204
- analyticsRepository.recordRequest(seedSlug).catch((error: unknown) => {
205
- console.error('Analytics middleware error:', error)
206
- })
207
- )
208
- })
209
-
210
- // 3. Auth Routes
211
- app.post('/auth/login', async (context) => {
212
- try {
213
- let body: any
214
- try { body = await context.req.json() } catch { return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
215
- const credentials = parseLoginBody(body)
216
- if (!credentials) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
217
- const { email, password } = credentials
218
- if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
219
-
220
- const clientIp = getClientIp(context.req)
221
- const loginRateLimit = await context.get('rateLimiters').getLimiter('login').checkLimit(clientIp)
222
- if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
223
-
224
- const user = await context.get('userRepository').findByEmail(email)
225
- const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH
226
- const isValid = await verifyPassword(password, hashToCompare, context.get('hashProvider'))
227
-
228
- if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
229
-
230
- const accessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined, surname: user.surname ?? undefined, role: user.role })
231
- const refreshToken = generateRefreshToken()
232
- const refreshTokenHash = await sha256hex(refreshToken)
233
- const nowSeconds = SystemClock.nowSeconds()
234
-
235
- await context.get('sessionRepository').saveRefreshToken({
236
- id: SystemIdGenerator.uuid(),
237
- userId: user.id,
238
- tokenHash: refreshTokenHash,
239
- expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
240
- })
241
- setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
242
- return context.json({ token: accessToken, expiresIn: '15m' }, 200)
243
- } catch (error) {
244
- return handleAuthError(context, error, 'Login')
245
- }
246
- })
247
-
248
- app.post('/auth/refresh', async (context) => {
249
- try {
250
- const refreshClientIp = getClientIp(context.req)
251
- const refreshRateLimit = await context.get('rateLimiters').getLimiter('tokenRefresh').checkLimit(refreshClientIp)
252
- if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
253
-
254
- const refreshToken = getCookie(context, 'refresh_token')
255
- if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
256
-
257
- const nowSeconds = SystemClock.nowSeconds()
258
- const tokenHash = await sha256hex(refreshToken)
259
- const activeSession = await context.get('sessionRepository').findActiveByHash(tokenHash, nowSeconds)
260
- if (!activeSession) return context.json({ error: 'Invalid refresh token' }, 401)
261
-
262
- const user = await context.get('userRepository').findById(activeSession.userId)
263
- if (!user) {
264
- await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
265
- return context.json({ error: 'User not found' }, 401)
266
- }
267
-
268
- // Issue new tokens before revoking the old one: if saveRefreshToken fails,
269
- // the old token stays valid and the user is not locked out.
270
- const newAccessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined, surname: user.surname ?? undefined, role: user.role })
271
- const newRefreshToken = generateRefreshToken()
272
- const newRefreshTokenHash = await sha256hex(newRefreshToken)
273
-
274
- await context.get('sessionRepository').saveRefreshToken({
275
- id: SystemIdGenerator.uuid(),
276
- userId: user.id,
277
- tokenHash: newRefreshTokenHash,
278
- expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
279
- })
280
-
281
- const revoked = await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
282
- if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
283
-
284
- setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
285
- return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
286
- } catch (error) {
287
- return handleAuthError(context, error, 'Refresh')
288
- }
289
- })
290
-
291
- app.post('/auth/logout', async (context) => {
292
- try {
293
- const refreshToken = getCookie(context, 'refresh_token')
294
- if (refreshToken) {
295
- const nowSeconds = SystemClock.nowSeconds()
296
- const tokenHash = await sha256hex(refreshToken)
297
- await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
298
- }
299
- deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
300
- return context.json({ message: 'Logged out' }, 200)
301
- } catch (error) {
302
- return handleAuthError(context, error, 'Logout')
303
- }
304
- })
305
-
306
- // 4. Setup & Password Reset
307
- app.route('/', setupApp)
308
- app.route('/', passwordResetApp)
309
-
310
- // 5. Protected CMS API
311
- const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
312
- apiProtected.use('*', authMiddleware())
313
-
314
- apiProtected.route('/settings', settingsApp)
315
- apiProtected.route('/schema', schemaApp)
316
- apiProtected.route('/seeds', seedsApp)
317
- apiProtected.route('/content', notificationsApp)
318
- apiProtected.route('/content', statsApp)
319
- apiProtected.route('/content', rotateFieldApp)
320
- apiProtected.route('/content', draftApp)
321
- apiProtected.route('/content', backrefsApp)
322
- apiProtected.route('/content', contentFeature)
323
- apiProtected.route('/widget', widgetApp)
324
- apiProtected.route('/automations', automationsApp)
325
- apiProtected.route('/', uploadRoutes)
326
-
327
- // 6. Public API (must be registered before apiProtected to avoid auth middleware interception)
328
- const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
329
- apiPublic.use('*', publicRateLimitMiddleware())
330
- apiPublic.use('*', apiKeyMiddleware())
331
- apiPublic.route('/', publicRoutes)
332
- app.route('/api/v1/public', apiPublic)
333
-
334
- // Webhooks API (public, verified via signature)
335
- app.route('/api/webhooks', webhooksApp)
336
-
337
- app.get('/api/media/:key', (context) => serveMediaHandler(context))
338
- app.route('/api', apiProtected)
339
- app.route('/api/search', searchRouter)
340
-
341
- // 7. Dashboard SPA — serve static assets from Workers Assets binding
342
- app.get('/admin', (context) => context.redirect('/admin/', 301))
343
- app.get('/admin/*', async (context) => {
344
- if (!context.env.ASSETS) {
345
- return context.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
346
- }
347
- const url = new URL(context.req.url)
348
- const originalPath = url.pathname
349
- url.pathname = originalPath.replace(/^\/admin/, '') || '/'
350
-
351
- let assetResponse = await context.env.ASSETS.fetch(new Request(url.toString(), context.req.raw))
352
- if (assetResponse.status === 404) {
353
- // SPA fallback: any unmatched /admin/* route serves index.html
354
- assetResponse = await context.env.ASSETS.fetch(new Request(new URL('/index.html', context.req.url)))
355
- }
356
- // ASSETS returns an immutable Response — wrap it to inject security headers
357
- const headers = new Headers(assetResponse.headers)
358
- headers.set('Content-Security-Policy',
359
- "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self'; frame-ancestors 'none'"
360
- )
361
- headers.set('X-Frame-Options', 'DENY')
362
- headers.set('X-Content-Type-Options', 'nosniff')
363
- headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
364
- return new Response(assetResponse.body, { status: assetResponse.status, headers })
365
- })
366
-
367
- return app
368
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /// <reference types="@cloudflare/workers-types" />
6
+ import { Hono } from 'hono'
7
+ import { cors } from 'hono/cors'
8
+ import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
9
+ import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
10
+ import { sha256hex, SystemClock, SystemIdGenerator } from '@beechcms/core'
11
+ import type { Env, Variables } from './types'
12
+ import { getClientIp } from './shared/request-utils'
13
+
14
+ // Imports delle rotte e middleware
15
+ import { AUTH_ERRORS } from './auth/constants'
16
+ import {
17
+ parseLoginBody,
18
+ validateLoginInput,
19
+ verifyPassword,
20
+ DUMMY_PASSWORD_HASH,
21
+ } from './auth/login'
22
+ import { generateRefreshToken } from './auth/refresh'
23
+ import { authMiddleware } from './middleware'
24
+ import contentFeature from './features/content'
25
+ import { widgetApp } from './widget'
26
+ import { rotateFieldApp } from './features/rotate-field'
27
+ import { passwordResetApp } from './features/password-reset'
28
+ import { setupApp } from './features/setup'
29
+ import { draftApp } from './features/draft'
30
+ import { settingsApp } from './features/settings/settings.handler'
31
+ import { schemaApp } from './features/schema/schema.handler'
32
+ import { dashboardLayoutApp } from './features/dashboard-layout'
33
+ import { seedsApp } from './features/seeds'
34
+ import { notificationsApp } from './features/notifications'
35
+ import { automationsApp } from './features/automations'
36
+ import { statsApp } from './features/stats'
37
+ import { backrefsApp } from './features/backrefs'
38
+ import { webhooksApp } from './features/webhooks'
39
+ import { uploadRoutes, serveMediaHandler } from './upload'
40
+ import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
41
+ import { searchRouter } from "./search"
42
+ import type { ISeedRepository } from '@beechcms/core'
43
+ import { repositoryMiddleware } from './middleware/repository.middleware'
44
+ import { seedRegistryMiddleware } from './middleware/seed-registry.middleware'
45
+ import { storageMiddleware } from './middleware/storage.middleware'
46
+ import { InMemorySeedRepository } from './shared/in-memory-seed.repository'
47
+ import { authProvidersMiddleware } from './middleware/auth-providers.middleware'
48
+ import { rateLimiterMiddleware } from './middleware/rate-limit.middleware'
49
+ import { observabilityMiddleware } from './middleware/observability.middleware'
50
+
51
+ export interface BeechConfig {
52
+ seeds: Seed[] | Record<string, Seed>
53
+ repository?: ContentRepository
54
+ idempotencyRepository?: IdempotencyRepository
55
+ bucket?: BeechBucket
56
+ mediaRepository?: MediaRepository
57
+ systemStatsRepository?: SystemStatsRepository
58
+ // Optional override: inject a custom ISeedRepository (e.g. InMemorySeedRepository for tests).
59
+ // When omitted and seeds is non-empty, an InMemorySeedRepository is created automatically
60
+ // for backward compatibility.
61
+ seedRepository?: ISeedRepository
62
+ }
63
+
64
+ // --- Costanti e helper ---
65
+ const REFRESH_TOKEN_EXPIRY_DAYS = 7
66
+ const SECONDS_PER_DAY = 24 * 60 * 60
67
+
68
+ function isRequestSecure(url: string): boolean {
69
+ return new URL(url).protocol === 'https:'
70
+ }
71
+
72
+ function getRefreshTokenCookieOptions(secure: boolean) {
73
+ return {
74
+ httpOnly: true,
75
+ secure,
76
+ sameSite: 'Strict' as const,
77
+ maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
78
+ path: '/auth',
79
+ }
80
+ }
81
+
82
+ function getRefreshTokenDeleteCookieOptions(secure: boolean) {
83
+ return {
84
+ httpOnly: true,
85
+ secure,
86
+ sameSite: 'Strict' as const,
87
+ path: '/auth',
88
+ }
89
+ }
90
+
91
+ function handleAuthError(context: any, error: unknown, operationName: string): Response {
92
+ if (context.env.ENV !== 'production') {
93
+ console.error(`${operationName} error:`, error)
94
+ }
95
+ return context.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
96
+ }
97
+
98
+ function extractPublicSeed(path: string): string {
99
+ const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
100
+ return match ? match[1] : ''
101
+ }
102
+
103
+ /**
104
+ * Builds a fully configured Hono app. Seeds are hydrated from D1 at runtime via
105
+ * seedRegistryMiddleware; config.seeds is retained for back-compat but no longer
106
+ * drives request handling.
107
+ */
108
+ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
109
+ // Back-compat shim: when callers (e.g. existing tests) pass seeds without a custom
110
+ // seedRepository, wrap them in an InMemorySeedRepository so the middleware can serve
111
+ // them without touching D1. Production index.ts passes seeds:[] so D1SeedRepository
112
+ // is used as the live path.
113
+ const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
114
+ const seedRepository = config.seedRepository
115
+ ?? (seedsArray.length > 0 ? new InMemorySeedRepository(seedsArray) : undefined)
116
+
117
+ const app = new Hono<{ Bindings: Env; Variables: Variables }>()
118
+
119
+ // 1. Repository Injection (must be first — seedRegistryMiddleware depends on seedRepository)
120
+ app.use('*', repositoryMiddleware({
121
+ repository: config.repository,
122
+ idempotencyRepository: config.idempotencyRepository,
123
+ mediaRepository: config.mediaRepository,
124
+ systemStatsRepository: config.systemStatsRepository,
125
+ seedRepository,
126
+ }))
127
+
128
+ // 2. Seed Registry Hydration (D1-backed, version-token-cached per isolate)
129
+ app.use('*', seedRegistryMiddleware())
130
+
131
+ // 3. Storage / Auth / Rate Limiting / Observability
132
+ app.use('*', storageMiddleware({
133
+ bucket: config.bucket,
134
+ }))
135
+
136
+ app.use('*', authProvidersMiddleware())
137
+ app.use('*', rateLimiterMiddleware())
138
+ app.use('*', observabilityMiddleware())
139
+
140
+ app.use('*', async (context, next) => {
141
+ const isDev = context.env.ENV !== 'production'
142
+
143
+ return cors({
144
+ origin: (origin) => {
145
+ if (!origin) return origin ?? ''
146
+
147
+ // In dev, allow all localhost/127.0.0.1 origins regardless of port
148
+ if (isDev) {
149
+ try {
150
+ const { hostname } = new URL(origin)
151
+ if (hostname === 'localhost' || hostname === '127.0.0.1') return origin
152
+ } catch {}
153
+ }
154
+
155
+ const origins = (context.env.CORS_ORIGINS ?? 'http://localhost:5173')
156
+ .split(',')
157
+ .map((o) => o.trim())
158
+ .filter(Boolean)
159
+
160
+ if (origins.includes(origin)) return origin
161
+
162
+ // Allow same-origin requests
163
+ try {
164
+ const originUrl = new URL(origin)
165
+ const requestUrl = new URL(context.req.url)
166
+ if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
167
+ return origin
168
+ }
169
+ } catch {}
170
+
171
+ return null
172
+ },
173
+ allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
174
+ allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
175
+ credentials: true,
176
+ })(context, next)
177
+ })
178
+
179
+ app.use('*', async (context, next) => {
180
+ await next()
181
+ if (context.req.path.startsWith('/admin')) return
182
+ context.header('X-Frame-Options', 'DENY')
183
+ context.header('X-Content-Type-Options', 'nosniff')
184
+ context.header('Referrer-Policy', 'strict-origin-when-cross-origin')
185
+ context.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
186
+ context.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
187
+ })
188
+
189
+ // 2. Analytics Middleware
190
+ app.use('/api/*', async (context, next) => {
191
+ await next()
192
+ if (context.req.method === 'OPTIONS') return
193
+ if (context.res.status < 200 || context.res.status >= 300) return
194
+
195
+ let executionCtx: any
196
+ try { executionCtx = context.executionCtx } catch {}
197
+ if (!executionCtx) return
198
+
199
+ const analyticsRepository = context.get('analyticsRepository')
200
+ if (!analyticsRepository) return
201
+
202
+ const seedSlug = extractPublicSeed(context.req.path)
203
+
204
+ executionCtx.waitUntil(
205
+ analyticsRepository.recordRequest(seedSlug).catch((error: unknown) => {
206
+ console.error('Analytics middleware error:', error)
207
+ })
208
+ )
209
+ })
210
+
211
+ // 3. Auth Routes
212
+ app.post('/auth/login', async (context) => {
213
+ try {
214
+ let body: any
215
+ try { body = await context.req.json() } catch { return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
216
+ const credentials = parseLoginBody(body)
217
+ if (!credentials) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
218
+ const { email, password } = credentials
219
+ if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
220
+
221
+ const clientIp = getClientIp(context.req)
222
+ const loginRateLimit = await context.get('rateLimiters').getLimiter('login').checkLimit(clientIp)
223
+ if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
224
+
225
+ const user = await context.get('userRepository').findByEmail(email)
226
+ const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH
227
+ const isValid = await verifyPassword(password, hashToCompare, context.get('hashProvider'))
228
+
229
+ if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
230
+
231
+ const accessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined, surname: user.surname ?? undefined, role: user.role })
232
+ const refreshToken = generateRefreshToken()
233
+ const refreshTokenHash = await sha256hex(refreshToken)
234
+ const nowSeconds = SystemClock.nowSeconds()
235
+
236
+ await context.get('sessionRepository').saveRefreshToken({
237
+ id: SystemIdGenerator.uuid(),
238
+ userId: user.id,
239
+ tokenHash: refreshTokenHash,
240
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
241
+ })
242
+ setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
243
+ return context.json({ token: accessToken, expiresIn: '15m' }, 200)
244
+ } catch (error) {
245
+ return handleAuthError(context, error, 'Login')
246
+ }
247
+ })
248
+
249
+ app.post('/auth/refresh', async (context) => {
250
+ try {
251
+ const refreshClientIp = getClientIp(context.req)
252
+ const refreshRateLimit = await context.get('rateLimiters').getLimiter('tokenRefresh').checkLimit(refreshClientIp)
253
+ if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
254
+
255
+ const refreshToken = getCookie(context, 'refresh_token')
256
+ if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
257
+
258
+ const nowSeconds = SystemClock.nowSeconds()
259
+ const tokenHash = await sha256hex(refreshToken)
260
+ const activeSession = await context.get('sessionRepository').findActiveByHash(tokenHash, nowSeconds)
261
+ if (!activeSession) return context.json({ error: 'Invalid refresh token' }, 401)
262
+
263
+ const user = await context.get('userRepository').findById(activeSession.userId)
264
+ if (!user) {
265
+ await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
266
+ return context.json({ error: 'User not found' }, 401)
267
+ }
268
+
269
+ // Issue new tokens before revoking the old one: if saveRefreshToken fails,
270
+ // the old token stays valid and the user is not locked out.
271
+ const newAccessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined, surname: user.surname ?? undefined, role: user.role })
272
+ const newRefreshToken = generateRefreshToken()
273
+ const newRefreshTokenHash = await sha256hex(newRefreshToken)
274
+
275
+ await context.get('sessionRepository').saveRefreshToken({
276
+ id: SystemIdGenerator.uuid(),
277
+ userId: user.id,
278
+ tokenHash: newRefreshTokenHash,
279
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
280
+ })
281
+
282
+ const revoked = await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
283
+ if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
284
+
285
+ setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
286
+ return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
287
+ } catch (error) {
288
+ return handleAuthError(context, error, 'Refresh')
289
+ }
290
+ })
291
+
292
+ app.post('/auth/logout', async (context) => {
293
+ try {
294
+ const refreshToken = getCookie(context, 'refresh_token')
295
+ if (refreshToken) {
296
+ const nowSeconds = SystemClock.nowSeconds()
297
+ const tokenHash = await sha256hex(refreshToken)
298
+ await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
299
+ }
300
+ deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
301
+ return context.json({ message: 'Logged out' }, 200)
302
+ } catch (error) {
303
+ return handleAuthError(context, error, 'Logout')
304
+ }
305
+ })
306
+
307
+ // 4. Setup & Password Reset
308
+ app.route('/', setupApp)
309
+ app.route('/', passwordResetApp)
310
+
311
+ // 5. Protected CMS API
312
+ const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
313
+ apiProtected.use('*', authMiddleware())
314
+
315
+ apiProtected.route('/settings', settingsApp)
316
+ apiProtected.route('/schema', schemaApp)
317
+ apiProtected.route('/dashboard-layout', dashboardLayoutApp)
318
+ apiProtected.route('/seeds', seedsApp)
319
+ apiProtected.route('/content', notificationsApp)
320
+ apiProtected.route('/content', statsApp)
321
+ apiProtected.route('/content', rotateFieldApp)
322
+ apiProtected.route('/content', draftApp)
323
+ apiProtected.route('/content', backrefsApp)
324
+ apiProtected.route('/content', contentFeature)
325
+ apiProtected.route('/widget', widgetApp)
326
+ apiProtected.route('/automations', automationsApp)
327
+ apiProtected.route('/', uploadRoutes)
328
+
329
+ // 6. Public API (must be registered before apiProtected to avoid auth middleware interception)
330
+ const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
331
+ apiPublic.use('*', publicRateLimitMiddleware())
332
+ apiPublic.use('*', apiKeyMiddleware())
333
+ apiPublic.route('/', publicRoutes)
334
+ app.route('/api/v1/public', apiPublic)
335
+
336
+ // Webhooks API (public, verified via signature)
337
+ app.route('/api/webhooks', webhooksApp)
338
+
339
+ app.get('/api/media/:key', (context) => serveMediaHandler(context))
340
+ app.route('/api', apiProtected)
341
+ app.route('/api/search', searchRouter)
342
+
343
+ // 7. Dashboard SPA serve static assets from Workers Assets binding
344
+ app.get('/admin', (context) => context.redirect('/admin/', 301))
345
+ app.get('/admin/*', async (context) => {
346
+ if (!context.env.ASSETS) {
347
+ return context.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
348
+ }
349
+ const url = new URL(context.req.url)
350
+ const originalPath = url.pathname
351
+ url.pathname = originalPath.replace(/^\/admin/, '') || '/'
352
+
353
+ let assetResponse = await context.env.ASSETS.fetch(new Request(url.toString(), context.req.raw))
354
+ if (assetResponse.status === 404) {
355
+ // SPA fallback: any unmatched /admin/* route serves index.html
356
+ assetResponse = await context.env.ASSETS.fetch(new Request(new URL('/index.html', context.req.url)))
357
+ }
358
+ // ASSETS returns an immutable Response — wrap it to inject security headers
359
+ const headers = new Headers(assetResponse.headers)
360
+ headers.set('Content-Security-Policy',
361
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self'; frame-ancestors 'none'"
362
+ )
363
+ headers.set('X-Frame-Options', 'DENY')
364
+ headers.set('X-Content-Type-Options', 'nosniff')
365
+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
366
+ return new Response(assetResponse.body, { status: assetResponse.status, headers })
367
+ })
368
+
369
+ return app
370
+ }