@beechcms/api 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  2. package/assets/dashboard/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/auth/bcrypt-hash-provider.ts +20 -0
  5. package/src/auth/constants.ts +3 -3
  6. package/src/auth/generate-refresh-token.test.ts +19 -0
  7. package/src/auth/hash-provider.test.ts +46 -0
  8. package/src/auth/in-memory-hash-provider.ts +13 -0
  9. package/src/auth/jose-token-service.ts +55 -0
  10. package/src/auth/login.test.ts +92 -0
  11. package/src/auth/login.ts +15 -32
  12. package/src/auth/refresh.ts +0 -122
  13. package/src/auth/static-token-service.ts +18 -0
  14. package/src/auth/token-service.test.ts +82 -0
  15. package/src/factory.ts +70 -78
  16. package/src/features/content/handlers/create.ts +14 -10
  17. package/src/features/content/handlers/delete.ts +13 -9
  18. package/src/features/content/handlers/update.ts +13 -9
  19. package/src/features/draft/draft.handler.ts +23 -12
  20. package/src/features/notifications/notifications.handler.ts +25 -54
  21. package/src/features/password-reset/request.ts +17 -41
  22. package/src/features/password-reset/reset.ts +18 -54
  23. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  24. package/src/features/schema/schema.handler.ts +1 -1
  25. package/src/features/settings/settings.handler.ts +62 -175
  26. package/src/features/setup/index.ts +12 -17
  27. package/src/features/stats/stats.handler.ts +110 -138
  28. package/src/middleware/auth-providers.middleware.ts +32 -0
  29. package/src/middleware/observability.middleware.ts +52 -0
  30. package/src/middleware/rate-limit.middleware.ts +41 -0
  31. package/src/middleware/repository.middleware.ts +41 -5
  32. package/src/middleware.ts +15 -35
  33. package/src/public/public-add.ts +5 -15
  34. package/src/public/public-edit.ts +4 -3
  35. package/src/public/public-read.ts +3 -3
  36. package/src/public/public-routes.ts +2 -2
  37. package/src/public/query-builder.test.ts +220 -0
  38. package/src/public/rate-limit-middleware.ts +7 -19
  39. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  40. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  41. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  42. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  43. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  44. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  45. package/src/search-utils.test.ts +207 -0
  46. package/src/search-utils.ts +18 -1
  47. package/src/search.ts +24 -35
  48. package/src/shared/apply-policies.test.ts +77 -0
  49. package/src/shared/background-notification-service.test.ts +58 -0
  50. package/src/shared/background-notification-service.ts +48 -0
  51. package/src/shared/content-utils.test.ts +161 -0
  52. package/src/shared/content.repository.d1.test.ts +312 -0
  53. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  54. package/src/shared/d1-activity-log.repository.ts +101 -0
  55. package/src/shared/d1-activity-logger.test.ts +82 -0
  56. package/src/shared/d1-activity-logger.ts +63 -0
  57. package/src/shared/d1-analytics.repository.test.ts +74 -0
  58. package/src/shared/d1-analytics.repository.ts +81 -0
  59. package/src/shared/d1-content-scan.repository.ts +29 -0
  60. package/src/shared/d1-notification.repository.test.ts +124 -0
  61. package/src/shared/d1-notification.repository.ts +114 -0
  62. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  63. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  64. package/src/shared/d1-search.repository.test.ts +83 -0
  65. package/src/shared/d1-search.repository.ts +84 -0
  66. package/src/shared/d1-session.repository.test.ts +121 -0
  67. package/src/shared/d1-session.repository.ts +98 -0
  68. package/src/shared/d1-user.repository.test.ts +147 -0
  69. package/src/shared/d1-user.repository.ts +109 -0
  70. package/src/shared/d1-widget.repository.test.ts +217 -0
  71. package/src/shared/d1-widget.repository.ts +337 -0
  72. package/src/shared/fixed-clock.ts +21 -0
  73. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  74. package/src/shared/in-memory-activity-logger.ts +15 -0
  75. package/src/shared/in-memory-notification-service.ts +15 -0
  76. package/src/shared/media.repository.d1.test.ts +103 -0
  77. package/src/shared/media.repository.d1.ts +1 -1
  78. package/src/shared/request-utils.ts +22 -0
  79. package/src/shared/sequential-id-generator.ts +22 -0
  80. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  81. package/src/types.ts +20 -3
  82. package/src/upload.ts +14 -7
  83. package/src/widget.ts +112 -253
  84. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  85. package/src/shared/activity-logger.ts +0 -79
  86. package/src/shared/notification-service.ts +0 -56
@@ -1,10 +1,32 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
+ import { SystemClock } from '@beechcms/core'
3
4
  import type { Env, Variables } from '../../types'
4
5
  import { publicProblem } from '../../public/problem-details'
5
6
  import { cleanStr } from '../../shared/query-utils'
6
7
 
7
8
  const DATABASE_ERROR = 'Database error'
9
+ const INTERNAL_SERVER_ERROR = 'Internal Server Error'
10
+
11
+ const SECONDS_PER_MINUTE = 60
12
+ const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
13
+ const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
14
+ const HOURS_24_IN_SECONDS = 24 * SECONDS_PER_HOUR
15
+ const DAYS_7_IN_SECONDS = 7 * SECONDS_PER_DAY
16
+ const DAYS_30_IN_SECONDS = 30 * SECONDS_PER_DAY
17
+ const MAX_MEDIA_SCAN = 1000
18
+ const DEFAULT_LIMIT = 12
19
+ const MAX_LIMIT = 100
20
+ const R2_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024 // 10GB
21
+ const D1_MONTHLY_REQUESTS_LIMIT = 1000000 // 1M requests/month
22
+
23
+ function getMimeType(extension: string): string {
24
+ if (extension === 'pdf') {
25
+ return 'application/pdf'
26
+ }
27
+ const type = extension === 'jpg' ? 'jpeg' : (extension || 'jpeg')
28
+ return `image/${type}`
29
+ }
8
30
 
9
31
  const statsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
10
32
 
@@ -14,15 +36,14 @@ const statsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
14
36
  */
15
37
  statsApp.get('/stats/media-library', async (context) => {
16
38
  try {
17
- const { DB } = context.env
18
39
  const mediaRepository = context.get('mediaRepository')
19
- const limit = Math.min(parseInt(context.req.query('limit') ?? '12'), 100)
20
- const offset = parseInt(context.req.query('offset') ?? '0')
40
+ const limit = Math.min(Number.parseInt(context.req.query('limit') ?? String(DEFAULT_LIMIT), 10), MAX_LIMIT)
41
+ const offset = Number.parseInt(context.req.query('offset') ?? '0', 10)
21
42
  const mediaBaseUrl = (context.env.MEDIA_BASE_URL?.trim().replace(/\/$/, '')) ?? new URL(context.req.url).origin
22
43
 
23
44
  // 1. Files tracked in the media library
24
- // Take the first 1000 for cross-scanning
25
- const { items: mediaRows } = await mediaRepository.list({ limit: 1000, offset: 0 })
45
+ // Take the first MAX_MEDIA_SCAN for cross-scanning
46
+ const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
26
47
 
27
48
  const trackedKeys = new Set<string>()
28
49
  const allItems: Array<{ key: string; filename: string; mime_type: string; size_bytes: number; created_at: number; url: string }> = []
@@ -33,40 +54,25 @@ statsApp.get('/stats/media-library', async (context) => {
33
54
  }
34
55
 
35
56
  // 2. /api/media/ URLs in the file columns of each seed (v0.4.0 — real columns)
36
- const MEDIA_KEY_REGEX = /\/api\/media\/([^"'\s\\,}\]]+)/g
37
- const seeds = Object.values(context.get('seedRegistry'))
38
-
39
- for (const seed of seeds) {
40
- const fileBranches = seed.branches.filter(branch => branch.type === 'file')
41
- if (fileBranches.length === 0) continue
42
-
43
- const columns = fileBranches.map(branch => branch.alias).join(', ')
44
- const whereClause = fileBranches.map(branch => `${branch.alias} LIKE '%/api/media/%'`).join(' OR ')
45
-
46
- const rows = await DB.prepare(
47
- `SELECT ${columns} FROM content_${seed.slug} WHERE ${whereClause}`
48
- ).all<Record<string, string | null>>()
49
-
50
- for (const row of rows.results ?? []) {
51
- const combinedValues = Object.values(row).filter(Boolean).join(' ')
52
- for (const match of combinedValues.matchAll(MEDIA_KEY_REGEX)) {
53
- const key = decodeURIComponent(match[1])
54
- if (trackedKeys.has(key)) continue
55
- trackedKeys.add(key)
56
- const filename = key.replace(/^\d+-/, '')
57
- const extension = filename.split('.').pop()?.toLowerCase() ?? ''
58
- const mimeType = extension === 'pdf' ? 'application/pdf' : `image/${extension === 'jpg' ? 'jpeg' : extension || 'jpeg'}`
59
- const createdAt = parseInt(key.split('-')[0]) || 0
60
- allItems.push({
61
- key,
62
- filename,
63
- mime_type: mimeType,
64
- size_bytes: 0,
65
- created_at: createdAt,
66
- url: `${mediaBaseUrl}/api/media/${encodeURIComponent(key)}`
67
- })
68
- }
69
- }
57
+ const seeds = context.get('seedRegistry').all()
58
+ const contentScanRepository = context.get('contentScanRepository')
59
+ const referencedKeysFromContent = await contentScanRepository.getReferencedMediaKeys(seeds)
60
+
61
+ for (const key of referencedKeysFromContent) {
62
+ if (trackedKeys.has(key)) continue
63
+ trackedKeys.add(key)
64
+ const filename = key.replace(/^\d+-/, '')
65
+ const extension = filename.split('.').pop()?.toLowerCase() ?? ''
66
+ const mimeType = getMimeType(extension)
67
+ const createdAt = Number.parseInt(key.split('-')[0], 10) || 0
68
+ allItems.push({
69
+ key,
70
+ filename,
71
+ mime_type: mimeType,
72
+ size_bytes: 0,
73
+ created_at: createdAt,
74
+ url: `${mediaBaseUrl}/api/media/${encodeURIComponent(key)}`
75
+ })
70
76
  }
71
77
 
72
78
  allItems.sort((a, b) => b.created_at - a.created_at)
@@ -76,7 +82,7 @@ statsApp.get('/stats/media-library', async (context) => {
76
82
  return context.json({ items: paginatedItems, total })
77
83
  } catch (error) {
78
84
  console.error('Media library error:', error)
79
- return context.json({ error: 'Internal Server Error' }, 500)
85
+ return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
80
86
  }
81
87
  })
82
88
 
@@ -85,40 +91,25 @@ statsApp.get('/stats/media-library', async (context) => {
85
91
  */
86
92
  statsApp.get('/stats/unused-media', async (context) => {
87
93
  try {
88
- const { DB } = context.env
89
94
  const mediaRepository = context.get('mediaRepository')
90
- const seeds = Object.values(context.get('seedRegistry'))
95
+ const seeds = context.get('seedRegistry').all()
91
96
 
92
97
  // All tracked media keys
93
- const { items: mediaRows } = await mediaRepository.list({ limit: 1000, offset: 0 })
98
+ const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
94
99
 
95
100
  if (mediaRows.length === 0) {
96
101
  return context.json({ items: [] })
97
102
  }
98
103
 
99
104
  // Collect all referenced keys in the file columns of each seed
100
- const referencedKeys = new Set<string>()
101
- for (const seed of seeds) {
102
- const fileBranches = seed.branches.filter(branch => branch.type === 'file')
103
- if (fileBranches.length === 0) continue
104
- const columns = fileBranches.map(branch => branch.alias).join(', ')
105
- const rows = await DB.prepare(
106
- `SELECT ${columns} FROM content_${seed.slug}`
107
- ).all<Record<string, string | null>>()
108
-
109
- for (const row of rows.results ?? []) {
110
- const combinedValues = Object.values(row).filter(Boolean).join(' ')
111
- for (const match of combinedValues.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
112
- referencedKeys.add(decodeURIComponent(match[1]))
113
- }
114
- }
115
- }
105
+ const contentScanRepository = context.get('contentScanRepository')
106
+ const referencedKeys = await contentScanRepository.getReferencedMediaKeys(seeds)
116
107
 
117
108
  const unusedMedia = mediaRows.filter(mediaItem => !referencedKeys.has(mediaItem.key))
118
109
  return context.json({ items: unusedMedia })
119
110
  } catch (error) {
120
111
  console.error('Unused media error:', error)
121
- return context.json({ error: 'Internal Server Error' }, 500)
112
+ return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
122
113
  }
123
114
  })
124
115
 
@@ -128,7 +119,7 @@ statsApp.get('/stats/unused-media', async (context) => {
128
119
  statsApp.get('/stats/setup-checklist', async (context) => {
129
120
  try {
130
121
  const { DB } = context.env
131
- const seeds = Object.values(context.get('seedRegistry'))
122
+ const seeds = context.get('seedRegistry').all()
132
123
 
133
124
  // 1. System tables present
134
125
  const systemTableNames = [
@@ -191,41 +182,38 @@ statsApp.get('/stats/setup-checklist', async (context) => {
191
182
  */
192
183
  statsApp.get('/stats/total', async (context) => {
193
184
  try {
194
- const { DB } = context.env
195
- const now = Math.floor(Date.now() / 1000)
196
- const twentyFourHoursAgo = now - (24 * 60 * 60)
197
- const sevenDaysAgo = now - (7 * 24 * 60 * 60)
198
- const thirtyDaysAgo = now - (30 * 24 * 60 * 60)
185
+ const now = SystemClock.nowSeconds()
186
+ const twentyFourHoursAgo = now - HOURS_24_IN_SECONDS
187
+ const sevenDaysAgo = now - DAYS_7_IN_SECONDS
188
+ const thirtyDaysAgo = now - DAYS_30_IN_SECONDS
199
189
 
200
190
  // Total: SUM of per-seed counts (current live entries)
201
- const seeds = Object.values(context.get('seedRegistry'))
191
+ const seeds = context.get('seedRegistry').all()
192
+ const widgetRepository = context.get('widgetRepository')
202
193
  const countResults = await Promise.all(
203
- seeds.map(seed => DB.prepare(`SELECT COUNT(*) as count FROM content_${seed.slug}`).first<{ count: number }>())
194
+ seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
204
195
  )
205
- const totalEntriesCount = countResults.reduce((accumulator, result) => accumulator + (result?.count ?? 0), 0)
196
+ const totalEntriesCount = countResults.reduce((accumulator, count) => accumulator + count, 0)
206
197
 
207
198
  // today/week/month: create events in activity_logs (entity_type = 'content')
208
- const eventRow = await DB.prepare(
209
- `SELECT
210
- COUNT(CASE WHEN created_at >= ? THEN 1 END) as today,
211
- COUNT(CASE WHEN created_at >= ? THEN 1 END) as week,
212
- COUNT(CASE WHEN created_at >= ? THEN 1 END) as month
213
- FROM activity_logs WHERE action = 'create' AND entity_type = 'content'`
214
- )
215
- .bind(twentyFourHoursAgo, sevenDaysAgo, thirtyDaysAgo)
216
- .first<{ today: number; week: number; month: number }>()
199
+ const activityLogRepository = context.get('activityLogRepository')
200
+ const [todayCount, weekCount, monthCount] = await Promise.all([
201
+ activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: twentyFourHoursAgo }),
202
+ activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: sevenDaysAgo }),
203
+ activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: thirtyDaysAgo }),
204
+ ])
217
205
 
218
206
  return context.json({
219
207
  total: totalEntriesCount,
220
- today: eventRow?.today ?? 0,
221
- week: eventRow?.week ?? 0,
222
- month: eventRow?.month ?? 0,
208
+ today: todayCount,
209
+ week: weekCount,
210
+ month: monthCount,
223
211
  })
224
212
  } catch (error) {
225
213
  console.error('Content stats error:', error)
226
214
  return publicProblem(context, {
227
215
  type: 'content-database-error',
228
- title: 'Internal Server Error',
216
+ title: INTERNAL_SERVER_ERROR,
229
217
  status: 500,
230
218
  detail: DATABASE_ERROR,
231
219
  })
@@ -240,25 +228,27 @@ statsApp.get('/stats/total', async (context) => {
240
228
  */
241
229
  statsApp.get('/stats/recent-activity', async (context) => {
242
230
  try {
243
- const { DB } = context.env
244
231
  const slug = cleanStr(context.req.query('slug'))
245
232
 
246
- let query = `SELECT id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details, created_at
247
- FROM activity_logs`
248
- const queryParameters: any[] = []
249
-
250
- if (slug) {
251
- query += ' WHERE entity_slug = ?'
252
- queryParameters.push(slug)
253
- }
254
-
255
- query += ' ORDER BY created_at DESC LIMIT 15'
256
-
257
- const result = await DB.prepare(query).bind(...queryParameters).all()
233
+ const entries = await context.get('activityLogRepository').list({
234
+ entitySlug: slug ?? undefined,
235
+ limit: 15,
236
+ })
258
237
 
259
- const activities = (result.results ?? []).map((row: any) => ({
260
- ...row,
261
- details: row.details ? JSON.parse(row.details) : null
238
+ // Preserve the legacy snake_case wire format consumed by the dashboard
239
+ // recent-activity widget. The repository returns camelCase records,
240
+ // so we reshape only on the way out.
241
+ const activities = entries.map((entry) => ({
242
+ id: entry.id,
243
+ user_id: entry.userId,
244
+ user_email: entry.userEmail,
245
+ user_name: entry.userName,
246
+ action: entry.action,
247
+ entity_type: entry.entityType,
248
+ entity_id: entry.entityId,
249
+ entity_slug: entry.entitySlug,
250
+ details: entry.details,
251
+ created_at: entry.createdAt,
262
252
  }))
263
253
 
264
254
  context.header('Cache-Control', 'no-store')
@@ -268,7 +258,7 @@ statsApp.get('/stats/recent-activity', async (context) => {
268
258
  console.error('Recent activity error:', error)
269
259
  return publicProblem(context, {
270
260
  type: 'content-database-error',
271
- title: 'Internal Server Error',
261
+ title: INTERNAL_SERVER_ERROR,
272
262
  status: 500,
273
263
  detail: DATABASE_ERROR,
274
264
  })
@@ -280,7 +270,6 @@ statsApp.get('/stats/recent-activity', async (context) => {
280
270
  */
281
271
  statsApp.get('/stats/health', async (context) => {
282
272
  try {
283
- const { DB } = context.env
284
273
  const systemStatsRepository = context.get('systemStatsRepository')
285
274
 
286
275
  // 1. Retrieve storage usage from repository (system_stats)
@@ -292,16 +281,10 @@ statsApp.get('/stats/health', async (context) => {
292
281
  }
293
282
 
294
283
  // 2. Aggregate D1 requests (proxy for database health) - last 30 days
295
- const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000)
296
- const d1Stats = await DB.prepare(
297
- `SELECT SUM(value) as total_requests FROM analytics WHERE metric = 'requests' AND seed = '' AND day_ts >= ?`
298
- ).bind(thirtyDaysAgo).first<{ total_requests: number }>()
299
-
300
- const totalRequests = d1Stats?.total_requests ?? 0
301
-
302
- // 3. Define limits (Cloudflare Free Tier as reference)
303
- const R2_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024 // 10GB
304
- const D1_MONTHLY_REQUESTS_LIMIT = 1000000 // Simulate a limit of 1M requests/month
284
+ const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
285
+ const totalRequests = await context
286
+ .get('analyticsRepository')
287
+ .sumByMetric('requests', '', thirtyDaysAgo)
305
288
 
306
289
  const storagePercentage = Math.min(Math.round((storageUsedBytes / R2_STORAGE_LIMIT) * 1000) / 10, 100)
307
290
  const d1Percentage = Math.min(Math.round((totalRequests / D1_MONTHLY_REQUESTS_LIMIT) * 1000) / 10, 100)
@@ -318,7 +301,7 @@ statsApp.get('/stats/health', async (context) => {
318
301
  percentage: d1Percentage
319
302
  },
320
303
  status: (storagePercentage < 90 && d1Percentage < 90) ? 'healthy' : 'warning',
321
- lastUpdate: Math.floor(Date.now() / 1000)
304
+ lastUpdate: SystemClock.nowSeconds()
322
305
  })
323
306
  } catch (error) {
324
307
  console.error('System health stats error:', error)
@@ -331,28 +314,17 @@ statsApp.get('/stats/health', async (context) => {
331
314
  */
332
315
  statsApp.get('/stats/cloudflare', async (context) => {
333
316
  try {
334
- const { DB } = context.env
335
- const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000)
336
-
337
- // Retrieve sum of metrics in the last 30 days
338
- const metricsResult = await DB.prepare(
339
- `SELECT
340
- metric,
341
- SUM(value) as total_value
342
- FROM analytics
343
- WHERE day_ts >= ? AND seed = ''
344
- GROUP BY metric`
345
- )
346
- .bind(thirtyDaysAgo)
347
- .all<{ metric: string; total_value: number }>()
317
+ const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
318
+ const analyticsRepository = context.get('analyticsRepository')
348
319
 
349
- const statsMap = Object.fromEntries(
350
- metricsResult.results?.map(metricRow => [metricRow.metric, metricRow.total_value]) ?? []
351
- )
320
+ const [recordedRequests, recordedVisitors] = await Promise.all([
321
+ analyticsRepository.sumByMetric('requests', '', thirtyDaysAgo),
322
+ analyticsRepository.sumByMetric('visitors', '', thirtyDaysAgo),
323
+ ])
352
324
 
353
325
  // Simulate some Cloudflare metrics not directly tracked for a premium feel
354
- const requestsCount = statsMap['requests'] ?? Math.floor(Math.random() * 5000) + 1000
355
- const visitorsCount = statsMap['visitors'] ?? Math.floor(requestsCount / 12) + 1
326
+ const requestsCount = recordedRequests > 0 ? recordedRequests : Math.floor(Math.random() * 5000) + 1000
327
+ const visitorsCount = recordedVisitors > 0 ? recordedVisitors : Math.floor(requestsCount / 12) + 1
356
328
  const bandwidthMB = Math.round((requestsCount * 0.15) * 10) / 10
357
329
 
358
330
  const systemStatsRepository = context.get('systemStatsRepository')
@@ -400,7 +372,7 @@ statsApp.get('/stats/cloudflare', async (context) => {
400
372
  console.error('Cloudflare stats error:', error)
401
373
  return publicProblem(context, {
402
374
  type: 'content-database-error',
403
- title: 'Internal Server Error',
375
+ title: INTERNAL_SERVER_ERROR,
404
376
  status: 500,
405
377
  detail: DATABASE_ERROR,
406
378
  })
@@ -412,17 +384,17 @@ statsApp.get('/stats/cloudflare', async (context) => {
412
384
  */
413
385
  statsApp.get('/stats/breakdown', async (context) => {
414
386
  try {
415
- const { DB } = context.env
416
- const seeds = Object.values(context.get('seedRegistry'))
387
+ const seeds = context.get('seedRegistry').all()
417
388
 
389
+ const widgetRepository = context.get('widgetRepository')
418
390
  const counts = await Promise.all(
419
- seeds.map(seed => DB.prepare(`SELECT COUNT(*) as count FROM content_${seed.slug}`).first<{ count: number }>())
391
+ seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
420
392
  )
421
-
393
+
422
394
  const breakdown = seeds.map((seed, index) => ({
423
395
  slug: seed.slug,
424
396
  label: seed.labelPlural || seed.label,
425
- count: counts[index]?.count ?? 0,
397
+ count: counts[index] ?? 0,
426
398
  }))
427
399
 
428
400
  return context.json(breakdown)
@@ -430,7 +402,7 @@ statsApp.get('/stats/breakdown', async (context) => {
430
402
  console.error('Breakdown stats error:', error)
431
403
  return publicProblem(context, {
432
404
  type: 'content-database-error',
433
- title: 'Internal Server Error',
405
+ title: INTERNAL_SERVER_ERROR,
434
406
  status: 500,
435
407
  detail: DATABASE_ERROR,
436
408
  })
@@ -451,7 +423,7 @@ statsApp.post('/stats/storage/sync', async (context) => {
451
423
  return context.json({ success: true, size: realSize })
452
424
  } catch (error) {
453
425
  console.error('Storage sync error:', error)
454
- return context.json({ error: 'Internal Server Error' }, 500)
426
+ return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
455
427
  }
456
428
  })
457
429
 
@@ -0,0 +1,32 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import type { IHashProvider, ITokenService, IClock } from '@beechcms/core'
3
+ import { SystemClock } from '@beechcms/core'
4
+ import type { AppEnv } from '../types'
5
+ import { BcryptHashProvider } from '../auth/bcrypt-hash-provider'
6
+ import { JoseTokenService } from '../auth/jose-token-service'
7
+
8
+ export interface AuthProviderOverrides {
9
+ hashProvider?: IHashProvider
10
+ tokenService?: ITokenService
11
+ clock?: IClock
12
+ }
13
+
14
+ export const authProvidersMiddleware = (overrides?: AuthProviderOverrides) => {
15
+ return createMiddleware<AppEnv>(async (context, next) => {
16
+ const resolvedClock = overrides?.clock ?? SystemClock
17
+ const hashProvider = overrides?.hashProvider ?? new BcryptHashProvider()
18
+ const tokenService = overrides?.tokenService ?? new JoseTokenService(
19
+ context.env.JWT_SECRET,
20
+ {
21
+ issuer: context.env.JWT_ISSUER,
22
+ audience: context.env.JWT_AUDIENCE,
23
+ },
24
+ resolvedClock,
25
+ )
26
+
27
+ context.set('hashProvider', hashProvider)
28
+ context.set('tokenService', tokenService)
29
+
30
+ await next()
31
+ })
32
+ }
@@ -0,0 +1,52 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import type { IActivityLogger, INotificationService, IClock, IIdGenerator } from '@beechcms/core'
3
+ import { SystemClock, SystemIdGenerator } from '@beechcms/core'
4
+ import type { AppEnv } from '../types'
5
+ import { D1ActivityLogger } from '../shared/d1-activity-logger'
6
+ import { BackgroundNotificationService } from '../shared/background-notification-service'
7
+
8
+ export interface ObservabilityOverrides {
9
+ activityLogger?: IActivityLogger
10
+ notificationService?: INotificationService
11
+ clock?: IClock
12
+ idGenerator?: IIdGenerator
13
+ }
14
+
15
+ /**
16
+ * Observability middleware.
17
+ *
18
+ * Injects the activity logger and the notification service into the Hono
19
+ * context. Both depend on `executionCtx.waitUntil` to fire-and-forget their
20
+ * persistence work in production. The notification service additionally
21
+ * depends on the notification repository — `repositoryMiddleware` MUST run
22
+ * before this middleware in the factory pipeline.
23
+ *
24
+ * Tests can pass their own implementations via `overrides` to bypass D1.
25
+ */
26
+ export const observabilityMiddleware = (overrides?: ObservabilityOverrides) => {
27
+ return createMiddleware<AppEnv>(async (context, next) => {
28
+ let scheduleBackgroundTask: ((task: Promise<unknown>) => void) | undefined
29
+ try {
30
+ const executionContext = context.executionCtx
31
+ scheduleBackgroundTask = executionContext.waitUntil.bind(executionContext)
32
+ } catch {
33
+ scheduleBackgroundTask = undefined
34
+ }
35
+
36
+ const resolvedClock = overrides?.clock ?? SystemClock
37
+ const resolvedIdGenerator = overrides?.idGenerator ?? SystemIdGenerator
38
+
39
+ const activityLogger =
40
+ overrides?.activityLogger ??
41
+ new D1ActivityLogger(context.env.DB, resolvedClock, resolvedIdGenerator, scheduleBackgroundTask)
42
+
43
+ const notificationService =
44
+ overrides?.notificationService ??
45
+ new BackgroundNotificationService(context.get('notificationRepository'), scheduleBackgroundTask)
46
+
47
+ context.set('activityLogger', activityLogger)
48
+ context.set('notificationService', notificationService)
49
+
50
+ await next()
51
+ })
52
+ }
@@ -0,0 +1,41 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { createMiddleware } from 'hono/factory'
3
+ import type { IRateLimiter } from '@beechcms/core'
4
+ import type { AppEnv, Env } from '../types'
5
+ import { CloudflareRateLimiter } from '../rate-limit/cloudflare-rate-limiter'
6
+ import { NoOpRateLimiter } from '../rate-limit/no-op-rate-limiter'
7
+
8
+ export type RateLimiterName =
9
+ | 'login'
10
+ | 'tokenRefresh'
11
+ | 'forgotPassword'
12
+ | 'resetPassword'
13
+ | 'publicApiRead'
14
+ | 'publicApiWrite'
15
+
16
+ export interface IRateLimiterRegistry {
17
+ getLimiter(name: RateLimiterName): IRateLimiter
18
+ }
19
+
20
+ const NO_OP = new NoOpRateLimiter()
21
+
22
+ function buildDefaultRegistry(env: Env): IRateLimiterRegistry {
23
+ const limiters: Record<RateLimiterName, IRateLimiter> = {
24
+ login: env.LOGIN_RATE_LIMITER ? new CloudflareRateLimiter(env.LOGIN_RATE_LIMITER) : NO_OP,
25
+ tokenRefresh: env.REFRESH_RATE_LIMITER ? new CloudflareRateLimiter(env.REFRESH_RATE_LIMITER) : NO_OP,
26
+ forgotPassword: env.FORGOT_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.FORGOT_PASSWORD_RATE_LIMITER) : NO_OP,
27
+ resetPassword: env.RESET_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.RESET_PASSWORD_RATE_LIMITER) : NO_OP,
28
+ publicApiRead: env.PUBLIC_READ_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_READ_RATE_LIMITER) : NO_OP,
29
+ publicApiWrite: env.PUBLIC_WRITE_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_WRITE_RATE_LIMITER) : NO_OP,
30
+ }
31
+
32
+ return { getLimiter: (name) => limiters[name] }
33
+ }
34
+
35
+ export const rateLimiterMiddleware = (overrides?: { registry?: IRateLimiterRegistry }) => {
36
+ return createMiddleware<AppEnv>(async (context, next) => {
37
+ const registry = overrides?.registry ?? buildDefaultRegistry(context.env)
38
+ context.set('rateLimiters', registry)
39
+ await next()
40
+ })
41
+ }
@@ -3,7 +3,17 @@ import { D1ContentRepository } from '../shared/content.repository.d1'
3
3
  import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
4
4
  import { D1MediaRepository } from '../shared/media.repository.d1'
5
5
  import { D1SystemStatsRepository } from '../shared/system-stats.repository.d1'
6
- import type { ContentRepository, IdempotencyRepository, MediaRepository, SystemStatsRepository } from '@beechcms/core'
6
+ import { D1UserRepository } from '../shared/d1-user.repository'
7
+ import { D1SessionRepository } from '../shared/d1-session.repository'
8
+ import { D1PasswordResetTokenRepository } from '../shared/d1-password-reset-token.repository'
9
+ import { D1ActivityLogRepository } from '../shared/d1-activity-log.repository'
10
+ import { D1NotificationRepository } from '../shared/d1-notification.repository'
11
+ import { D1WidgetRepository } from '../shared/d1-widget.repository'
12
+ import { D1SearchRepository } from '../shared/d1-search.repository'
13
+ import { D1AnalyticsRepository } from '../shared/d1-analytics.repository'
14
+ import { D1ContentScanRepository } from '../shared/d1-content-scan.repository'
15
+ import { SystemClock, SystemIdGenerator } from '@beechcms/core'
16
+ import type { ContentRepository, IdempotencyRepository, MediaRepository, SystemStatsRepository, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogRepository, INotificationRepository, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, IClock, IIdGenerator } from '@beechcms/core'
7
17
  import type { Env, Variables } from '../types'
8
18
 
9
19
  interface RepositoryOverrides {
@@ -11,14 +21,40 @@ interface RepositoryOverrides {
11
21
  idempotencyRepository?: IdempotencyRepository
12
22
  mediaRepository?: MediaRepository
13
23
  systemStatsRepository?: SystemStatsRepository
24
+ userRepository?: IUserRepository
25
+ sessionRepository?: ISessionRepository
26
+ passwordResetTokenRepository?: IPasswordResetTokenRepository
27
+ activityLogRepository?: IActivityLogRepository
28
+ notificationRepository?: INotificationRepository
29
+ widgetRepository?: IWidgetRepository
30
+ searchRepository?: ISearchRepository
31
+ analyticsRepository?: IAnalyticsRepository
32
+ contentScanRepository?: IContentScanRepository
33
+ clock?: IClock
34
+ idGenerator?: IIdGenerator
14
35
  }
15
36
 
16
37
  export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
17
38
  return createMiddleware<{ Bindings: Env; Variables: Variables }>(async (context, next) => {
18
- context.set('repository', overrides?.repository ?? new D1ContentRepository(context.env.DB))
19
- context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(context.env.DB))
20
- context.set('mediaRepository', overrides?.mediaRepository ?? new D1MediaRepository(context.env.DB))
21
- context.set('systemStatsRepository', overrides?.systemStatsRepository ?? new D1SystemStatsRepository(context.env.DB))
39
+ const resolvedClock = overrides?.clock ?? SystemClock
40
+ const resolvedIdGenerator = overrides?.idGenerator ?? SystemIdGenerator
41
+ const database = context.env.DB
42
+
43
+ context.set('repository', overrides?.repository ?? new D1ContentRepository(database))
44
+ context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(database))
45
+ context.set('mediaRepository', overrides?.mediaRepository ?? new D1MediaRepository(database))
46
+ context.set('systemStatsRepository', overrides?.systemStatsRepository ?? new D1SystemStatsRepository(database))
47
+ context.set('userRepository', overrides?.userRepository ?? new D1UserRepository(database))
48
+ context.set('sessionRepository', overrides?.sessionRepository ?? new D1SessionRepository(database, resolvedClock))
49
+ context.set('passwordResetTokenRepository', overrides?.passwordResetTokenRepository ?? new D1PasswordResetTokenRepository(database, resolvedIdGenerator))
50
+ context.set('activityLogRepository', overrides?.activityLogRepository ?? new D1ActivityLogRepository(database))
51
+ context.set('notificationRepository', overrides?.notificationRepository ?? new D1NotificationRepository(database, resolvedClock, resolvedIdGenerator))
52
+ context.set('widgetRepository', overrides?.widgetRepository ?? new D1WidgetRepository(database))
53
+ context.set('searchRepository', overrides?.searchRepository ?? new D1SearchRepository(database))
54
+ context.set('analyticsRepository', overrides?.analyticsRepository ?? new D1AnalyticsRepository(database, resolvedClock))
55
+ context.set('contentScanRepository', overrides?.contentScanRepository ?? new D1ContentScanRepository(database))
56
+ context.set('clock', resolvedClock)
57
+ context.set('idGenerator', resolvedIdGenerator)
22
58
  await next()
23
59
  })
24
60
  }