@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
package/src/factory.ts CHANGED
@@ -3,24 +3,19 @@ import { Hono } from 'hono'
3
3
  import { cors } from 'hono/cors'
4
4
  import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
5
5
  import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
6
+ import { sha256hex, SystemClock, SystemIdGenerator, SeedRegistry } from '@beechcms/core'
6
7
  import type { Env, Variables } from './types'
8
+ import { getClientIp } from './shared/request-utils'
7
9
 
8
10
  // Imports delle rotte e middleware
9
11
  import { AUTH_ERRORS } from './auth/constants'
10
12
  import {
11
13
  parseLoginBody,
12
14
  validateLoginInput,
13
- findUserByEmail,
14
15
  verifyPassword,
15
16
  DUMMY_PASSWORD_HASH,
16
17
  } from './auth/login'
17
- import {
18
- generateRefreshToken,
19
- saveRefreshToken,
20
- generateAccessToken,
21
- validateRefreshToken,
22
- revokeRefreshToken,
23
- } from './auth/refresh'
18
+ import { generateRefreshToken } from './auth/refresh'
24
19
  import { authMiddleware } from './middleware'
25
20
  import contentFeature from './features/content'
26
21
  import { widgetApp } from './widget'
@@ -37,6 +32,9 @@ import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './pub
37
32
  import { searchRouter } from "./search"
38
33
  import { repositoryMiddleware } from './middleware/repository.middleware'
39
34
  import { storageMiddleware } from './middleware/storage.middleware'
35
+ import { authProvidersMiddleware } from './middleware/auth-providers.middleware'
36
+ import { rateLimiterMiddleware } from './middleware/rate-limit.middleware'
37
+ import { observabilityMiddleware } from './middleware/observability.middleware'
40
38
 
41
39
  export interface BeechConfig {
42
40
  seeds: Seed[] | Record<string, Seed>
@@ -55,10 +53,6 @@ function isRequestSecure(url: string): boolean {
55
53
  return new URL(url).protocol === 'https:'
56
54
  }
57
55
 
58
- function getClientIp(headers: Headers): string {
59
- return headers.get('cf-connecting-ip') ?? 'unknown'
60
- }
61
-
62
56
  function getRefreshTokenCookieOptions(secure: boolean) {
63
57
  return {
64
58
  httpOnly: true,
@@ -98,15 +92,14 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
98
92
  const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
99
93
  // Filter out any invalid objects that might have leaked into the registry (e.g. module exports)
100
94
  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
95
+ const seedRegistry = new SeedRegistry(validSeeds)
103
96
 
104
97
  const app = new Hono<{ Bindings: Env; Variables: Variables }>()
105
98
 
106
99
  // 1. Core Middleware (Seeds, CORS, Security)
107
100
  app.use('*', async (context, next) => {
108
- context.set('getSeed', getSeedFn)
109
- context.set('seedRegistry', registry)
101
+ context.set('getSeed', (slug: string) => seedRegistry.get(slug))
102
+ context.set('seedRegistry', seedRegistry)
110
103
  await next()
111
104
  })
112
105
 
@@ -122,6 +115,10 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
122
115
  bucket: config.bucket,
123
116
  }))
124
117
 
118
+ app.use('*', authProvidersMiddleware())
119
+ app.use('*', rateLimiterMiddleware())
120
+ app.use('*', observabilityMiddleware())
121
+
125
122
  app.use('*', async (context, next) => {
126
123
  const isDev = context.env.ENV !== 'production'
127
124
 
@@ -174,27 +171,23 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
174
171
  // 2. Analytics Middleware
175
172
  app.use('/api/*', async (context, next) => {
176
173
  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
- }
174
+ if (context.req.method === 'OPTIONS') return
175
+ if (context.res.status < 200 || context.res.status >= 300) return
176
+
177
+ let executionCtx: any
178
+ try { executionCtx = context.executionCtx } catch {}
179
+ if (!executionCtx) return
180
+
181
+ const analyticsRepository = context.get('analyticsRepository')
182
+ if (!analyticsRepository) return
183
+
184
+ const seedSlug = extractPublicSeed(context.req.path)
185
+
186
+ executionCtx.waitUntil(
187
+ analyticsRepository.recordRequest(seedSlug).catch((error: unknown) => {
188
+ console.error('Analytics middleware error:', error)
189
+ })
190
+ )
198
191
  })
199
192
 
200
193
  // 3. Auth Routes
@@ -207,28 +200,27 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
207
200
  const { email, password } = credentials
208
201
  if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
209
202
 
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
- }
203
+ const clientIp = getClientIp(context.req)
204
+ const loginRateLimit = await context.get('rateLimiters').getLimiter('login').checkLimit(clientIp)
205
+ if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
216
206
 
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)
207
+ const user = await context.get('userRepository').findByEmail(email)
208
+ const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH
209
+ const isValid = await verifyPassword(password, hashToCompare, context.get('hashProvider'))
221
210
 
222
211
  if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
223
212
 
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)
213
+ const accessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
229
214
  const refreshToken = generateRefreshToken()
230
-
231
- await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
215
+ const refreshTokenHash = await sha256hex(refreshToken)
216
+ const nowSeconds = SystemClock.nowSeconds()
217
+
218
+ await context.get('sessionRepository').saveRefreshToken({
219
+ id: SystemIdGenerator.uuid(),
220
+ userId: user.id,
221
+ tokenHash: refreshTokenHash,
222
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
223
+ })
232
224
  setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
233
225
  return context.json({ token: accessToken, expiresIn: '15m' }, 200)
234
226
  } catch (error) {
@@ -238,33 +230,34 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
238
230
 
239
231
  app.post('/auth/refresh', async (context) => {
240
232
  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
- }
233
+ const refreshClientIp = getClientIp(context.req)
234
+ const refreshRateLimit = await context.get('rateLimiters').getLimiter('tokenRefresh').checkLimit(refreshClientIp)
235
+ if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
247
236
 
248
237
  const refreshToken = getCookie(context, 'refresh_token')
249
238
  if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
250
239
 
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)
240
+ const nowSeconds = SystemClock.nowSeconds()
241
+ const tokenHash = await sha256hex(refreshToken)
242
+ const activeSession = await context.get('sessionRepository').findActiveByHash(tokenHash, nowSeconds)
243
+ if (!activeSession) return context.json({ error: 'Invalid refresh token' }, 401)
254
244
 
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 }>()
245
+ const user = await context.get('userRepository').findById(activeSession.userId)
256
246
  if (!user) return context.json({ error: 'User not found' }, 401)
257
247
 
258
- const revoked = await revokeRefreshToken(DB, refreshToken)
248
+ const revoked = await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
259
249
  if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
260
250
 
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)
251
+ const newAccessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
265
252
  const newRefreshToken = generateRefreshToken()
266
-
267
- await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
253
+ const newRefreshTokenHash = await sha256hex(newRefreshToken)
254
+
255
+ await context.get('sessionRepository').saveRefreshToken({
256
+ id: SystemIdGenerator.uuid(),
257
+ userId: user.id,
258
+ tokenHash: newRefreshTokenHash,
259
+ expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
260
+ })
268
261
  setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
269
262
  return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
270
263
  } catch (error) {
@@ -275,7 +268,11 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
275
268
  app.post('/auth/logout', async (context) => {
276
269
  try {
277
270
  const refreshToken = getCookie(context, 'refresh_token')
278
- if (refreshToken) await revokeRefreshToken(context.env.DB, refreshToken)
271
+ if (refreshToken) {
272
+ const nowSeconds = SystemClock.nowSeconds()
273
+ const tokenHash = await sha256hex(refreshToken)
274
+ await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
275
+ }
279
276
  deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
280
277
  return context.json({ message: 'Logged out' }, 200)
281
278
  } catch (error) {
@@ -289,12 +286,7 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
289
286
 
290
287
  // 5. Protected CMS API
291
288
  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
- })
289
+ apiProtected.use('*', authMiddleware())
298
290
 
299
291
  apiProtected.route('/settings', settingsApp)
300
292
  apiProtected.route('/schema', schemaApp)
@@ -8,7 +8,6 @@ import {
8
8
  import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
9
9
  import { publicProblem } from '../../../public/problem-details'
10
10
  import { CONTENT_ERRORS } from '../constants'
11
- import { logActivity } from '../../../shared/activity-logger'
12
11
  import { cleanStr } from '../../../shared/query-utils'
13
12
  import { AppEnv } from '../../../types'
14
13
 
@@ -110,7 +109,7 @@ export async function createHandler(context: Context<AppEnv>) {
110
109
  throw error
111
110
  }
112
111
 
113
- const id = crypto.randomUUID()
112
+ const id = context.get('idGenerator').uuid()
114
113
  let finalSlug = entrySlug
115
114
  if (!finalSlug) {
116
115
  const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
@@ -121,15 +120,20 @@ export async function createHandler(context: Context<AppEnv>) {
121
120
  const repository = context.get('repository')
122
121
  await repository.create(seed, id, finalSlug, status, privacyData)
123
122
 
124
- const userId = context.get('jwtPayload')?.sub
123
+ const jwtPayload = context.get('jwtPayload')
125
124
  const title = privacyData.title || privacyData.name || finalSlug
126
-
127
- logActivity(context, {
128
- action: 'create',
129
- entityType: 'content',
130
- entityId: id,
131
- entitySlug: slug,
132
- details: { title }
125
+
126
+ context.get('activityLogger').log({
127
+ action: 'create',
128
+ entityType: 'content',
129
+ entityId: id,
130
+ entitySlug: slug,
131
+ details: { title },
132
+ actor: {
133
+ id: jwtPayload.sub,
134
+ email: jwtPayload.email ?? 'unknown',
135
+ name: jwtPayload.name ?? null,
136
+ },
133
137
  })
134
138
 
135
139
  return context.json({ id }, 201)
@@ -4,7 +4,6 @@ import { deleteR2Objects } from '../../../upload'
4
4
  import { extractMediaKeysFromData } from '../../../media-utils'
5
5
  import { publicProblem } from '../../../public/problem-details'
6
6
  import { CONTENT_ERRORS } from '../constants'
7
- import { logActivity } from '../../../shared/activity-logger'
8
7
  import { AppEnv } from '../../../types'
9
8
 
10
9
  export async function deleteHandler(context: Context<AppEnv>) {
@@ -34,15 +33,20 @@ export async function deleteHandler(context: Context<AppEnv>) {
34
33
  // Repository.delete returns the row data for cleanup
35
34
  const { row } = await repository.delete(seed, entryId)
36
35
 
37
- const userId = context.get('jwtPayload')?.sub
36
+ const jwtPayload = context.get('jwtPayload')
38
37
  const title = row.title || row.name || entryId
39
-
40
- logActivity(context, {
41
- action: 'delete',
42
- entityType: 'content',
43
- entityId: entryId,
44
- entitySlug: schemaSlug,
45
- details: { title }
38
+
39
+ context.get('activityLogger').log({
40
+ action: 'delete',
41
+ entityType: 'content',
42
+ entityId: entryId,
43
+ entitySlug: schemaSlug,
44
+ details: { title },
45
+ actor: {
46
+ id: jwtPayload.sub,
47
+ email: jwtPayload.email ?? 'unknown',
48
+ name: jwtPayload.name ?? null,
49
+ },
46
50
  })
47
51
 
48
52
 
@@ -10,7 +10,6 @@ import {
10
10
  import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
11
11
  import { publicProblem } from '../../../public/problem-details'
12
12
  import { CONTENT_ERRORS } from '../constants'
13
- import { logActivity } from '../../../shared/activity-logger'
14
13
  import { cleanStr } from '../../../shared/query-utils'
15
14
  import { AppEnv } from '../../../types'
16
15
 
@@ -167,15 +166,20 @@ export async function updateHandler(context: Context<AppEnv>) {
167
166
 
168
167
  await repository.update(seed, id, mergedData, newStatus)
169
168
 
170
- const userId = context.get('jwtPayload')?.sub
169
+ const jwtPayload = context.get('jwtPayload')
171
170
  const title = mergedData.title || mergedData.name || newSlug
172
-
173
- logActivity(context, {
174
- action: 'update',
175
- entityType: 'content',
176
- entityId: id,
177
- entitySlug: slug,
178
- details: { title }
171
+
172
+ context.get('activityLogger').log({
173
+ action: 'update',
174
+ entityType: 'content',
175
+ entityId: id,
176
+ entitySlug: slug,
177
+ details: { title },
178
+ actor: {
179
+ id: jwtPayload.sub,
180
+ email: jwtPayload.email ?? 'unknown',
181
+ name: jwtPayload.name ?? null,
182
+ },
179
183
  })
180
184
 
181
185
  return context.json({ success: true })
@@ -6,7 +6,6 @@ import {
6
6
  EntryNotFoundError
7
7
  } from '@beechcms/core'
8
8
  import { publicProblem } from '../../public/problem-details'
9
- import { logActivity } from '../../shared/activity-logger'
10
9
  import { cleanStr } from '../../shared/query-utils'
11
10
  import { applyVisibility } from '../../shared/apply-policies'
12
11
  import { AppEnv } from '../../types'
@@ -114,14 +113,20 @@ draftApp.put('/:slug/:id/draft', async (context) => {
114
113
 
115
114
  await repository.saveDraft(seed, id, validation.data)
116
115
 
117
- logActivity(context, {
118
- action: 'update',
119
- entityType: 'content',
120
- entityId: id,
116
+ const draftSaveActor = context.get('jwtPayload')
117
+ context.get('activityLogger').log({
118
+ action: 'update',
119
+ entityType: 'content',
120
+ entityId: id,
121
121
  entitySlug: slug,
122
- details: {
123
- title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
124
- note: 'draft saved'
122
+ details: {
123
+ title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
124
+ note: 'draft saved',
125
+ },
126
+ actor: {
127
+ id: draftSaveActor.sub,
128
+ email: draftSaveActor.email ?? 'unknown',
129
+ name: draftSaveActor.name ?? null,
125
130
  },
126
131
  })
127
132
 
@@ -220,12 +225,18 @@ draftApp.post('/:slug/:id/draft/publish', async (context) => {
220
225
  const displayValue = draft[seed.displayNameAlias]
221
226
  const displayStr = typeof displayValue === 'string' ? displayValue : id
222
227
 
223
- logActivity(context, {
224
- action: 'update',
225
- entityType: 'content',
226
- entityId: id,
228
+ const draftPublishActor = context.get('jwtPayload')
229
+ context.get('activityLogger').log({
230
+ action: 'update',
231
+ entityType: 'content',
232
+ entityId: id,
227
233
  entitySlug: slug,
228
234
  details: { title: displayStr, note: 'draft published' },
235
+ actor: {
236
+ id: draftPublishActor.sub,
237
+ email: draftPublishActor.email ?? 'unknown',
238
+ name: draftPublishActor.name ?? null,
239
+ },
229
240
  })
230
241
 
231
242
  return context.json({ success: true })
@@ -3,47 +3,40 @@ import { Hono } from 'hono'
3
3
  import type { Env, Variables } from '../../types'
4
4
 
5
5
  /**
6
- * Notifications Feature Handler
6
+ * Notifications Feature Handler.
7
7
  *
8
- * Manages the retrieval, marking as read/unread, and deletion of system notifications.
9
- * Uses ETags for efficient client-side caching of the notification list.
8
+ * Manages retrieval, mark read/unread, and deletion of system notifications.
9
+ * All persistence goes through the {@link INotificationRepository} injected
10
+ * by `repositoryMiddleware`. The handler owns the HTTP concerns only:
11
+ * ETag negotiation, status codes, error mapping.
10
12
  */
11
13
  const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
12
14
 
15
+ const NOTIFICATION_LIST_LIMIT = 50
16
+
13
17
  /**
14
18
  * GET /notifications
15
- * Fetches the latest 50 notifications.
16
- * Implements ETag/304 Not Modified caching based on count and last update.
19
+ *
20
+ * Returns the most recent notifications. The ETag is built from aggregate
21
+ * stats so unchanged inboxes return 304 without serialising the full list.
17
22
  */
18
23
  notificationsApp.get('/notifications', async (context) => {
19
24
  try {
20
- const { DB } = context.env
21
-
22
- // Fetch aggregate stats to generate a robust ETag
23
- const stats = await DB.prepare(
24
- 'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
25
- ).first<{ count: number; latest: number | null; read_sum: number | null }>()
26
-
27
- const totalCount = stats?.count ?? 0
28
- const lastUpdateTimestamp = stats?.latest ?? 0
29
- const totalReadCount = stats?.read_sum ?? 0
30
-
31
- // ETag is derived from count, latest timestamp, and read status to ensure freshness
32
- const etag = `W/"${totalCount}-${lastUpdateTimestamp}-${totalReadCount}"`
25
+ const notificationRepository = context.get('notificationRepository')
26
+ const notificationStats = await notificationRepository.stats()
27
+ const etagValue = `W/"${notificationStats.totalCount}-${notificationStats.latestCreatedAt}-${notificationStats.readCount}"`
33
28
 
34
29
  const ifNoneMatch = context.req.header('If-None-Match')
35
- if (ifNoneMatch === etag) {
30
+ if (ifNoneMatch === etagValue) {
36
31
  return new Response(null, { status: 304 })
37
32
  }
38
33
 
39
- const dbResult = await DB.prepare(
40
- 'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
41
- ).all()
34
+ const notifications = await notificationRepository.list(NOTIFICATION_LIST_LIMIT)
42
35
 
43
- context.header('ETag', etag)
36
+ context.header('ETag', etagValue)
44
37
  context.header('Cache-Control', 'no-cache, must-revalidate')
45
38
 
46
- return context.json(dbResult.results ?? [])
39
+ return context.json(notifications)
47
40
  } catch (error) {
48
41
  console.error('[Notifications] Fetch error:', error)
49
42
  return context.json({ error: 'Failed to fetch notifications' }, 500)
@@ -51,18 +44,12 @@ notificationsApp.get('/notifications', async (context) => {
51
44
  })
52
45
 
53
46
  /**
54
- * PATCH /notifications/:id/read
55
- * Marks a specific notification as read.
47
+ * PATCH /notifications/:id/read — mark a single notification as read.
56
48
  */
57
49
  notificationsApp.patch('/notifications/:id/read', async (context) => {
58
50
  try {
59
51
  const notificationId = context.req.param('id')
60
- const { DB } = context.env
61
-
62
- await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
63
- .bind(notificationId)
64
- .run()
65
-
52
+ await context.get('notificationRepository').markRead(notificationId)
66
53
  return context.json({ success: true })
67
54
  } catch (error) {
68
55
  console.error('[Notifications] Mark read error:', error)
@@ -71,18 +58,12 @@ notificationsApp.patch('/notifications/:id/read', async (context) => {
71
58
  })
72
59
 
73
60
  /**
74
- * PATCH /notifications/:id/unread
75
- * Marks a specific notification as unread.
61
+ * PATCH /notifications/:id/unread — mark a single notification as unread.
76
62
  */
77
63
  notificationsApp.patch('/notifications/:id/unread', async (context) => {
78
64
  try {
79
65
  const notificationId = context.req.param('id')
80
- const { DB } = context.env
81
-
82
- await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
83
- .bind(notificationId)
84
- .run()
85
-
66
+ await context.get('notificationRepository').markUnread(notificationId)
86
67
  return context.json({ success: true })
87
68
  } catch (error) {
88
69
  console.error('[Notifications] Mark unread error:', error)
@@ -91,18 +72,12 @@ notificationsApp.patch('/notifications/:id/unread', async (context) => {
91
72
  })
92
73
 
93
74
  /**
94
- * DELETE /notifications/:id
95
- * Permanently deletes a notification.
75
+ * DELETE /notifications/:id — permanently remove a notification.
96
76
  */
97
77
  notificationsApp.delete('/notifications/:id', async (context) => {
98
78
  try {
99
79
  const notificationId = context.req.param('id')
100
- const { DB } = context.env
101
-
102
- await DB.prepare('DELETE FROM notifications WHERE id = ?')
103
- .bind(notificationId)
104
- .run()
105
-
80
+ await context.get('notificationRepository').delete(notificationId)
106
81
  return context.json({ success: true })
107
82
  } catch (error) {
108
83
  console.error('[Notifications] Delete error:', error)
@@ -111,15 +86,11 @@ notificationsApp.delete('/notifications/:id', async (context) => {
111
86
  })
112
87
 
113
88
  /**
114
- * POST /notifications/mark-all-read
115
- * Marks all notifications in the database as read.
89
+ * POST /notifications/mark-all-read — mark every notification as read.
116
90
  */
117
91
  notificationsApp.post('/notifications/mark-all-read', async (context) => {
118
92
  try {
119
- const { DB } = context.env
120
-
121
- await DB.prepare('UPDATE notifications SET is_read = 1').run()
122
-
93
+ await context.get('notificationRepository').markAllRead()
123
94
  return context.json({ success: true })
124
95
  } catch (error) {
125
96
  console.error('[Notifications] Mark all read error:', error)