@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
@@ -2,22 +2,11 @@
2
2
  import type { Context } from 'hono'
3
3
  import type { Env, Variables } from '../../types'
4
4
  import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
5
+ import { sha256hex } from '@beechcms/core'
6
+ import { getClientIp } from '../../shared/request-utils'
5
7
 
6
8
  const PASSWORD_RESET_TOKEN_EXPIRY_SECONDS = 30 * 60
7
9
 
8
- /**
9
- * Computes the SHA-256 hash of a string and returns it as a hex string.
10
- */
11
- async function computeSha256Hash(text: string): Promise<string> {
12
- const encoder = new TextEncoder()
13
- const data = encoder.encode(text)
14
- const hashBuffer = await crypto.subtle.digest('SHA-256', data)
15
-
16
- return Array.from(new Uint8Array(hashBuffer))
17
- .map(byte => byte.toString(16).padStart(2, '0'))
18
- .join('')
19
- }
20
-
21
10
  /**
22
11
  * Handles the password reset request.
23
12
  * Generates a reset token, stores its hash in the database, and sends an email to the user.
@@ -46,41 +35,30 @@ export async function requestPasswordReset(
46
35
  const normalizedEmail = emailInput.trim().toLowerCase()
47
36
  const emailLocale = resolveEmailLocale(payload.locale)
48
37
 
49
- // Rate limiting based on IP address
50
- if (env.FORGOT_PASSWORD_RATE_LIMITER) {
51
- const clientIpAddress = req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
52
- const { success: isRateLimitAllowed } = await env.FORGOT_PASSWORD_RATE_LIMITER.limit({ key: clientIpAddress })
53
-
54
- if (!isRateLimitAllowed) {
55
- return context.json({ error: 'Too many requests' }, 429)
56
- }
38
+ const clientIpAddress = getClientIp(req)
39
+ const forgotPasswordRateLimit = await context.get('rateLimiters').getLimiter('forgotPassword').checkLimit(clientIpAddress)
40
+ if (!forgotPasswordRateLimit.isAllowed) {
41
+ return context.json({ error: 'Too many requests' }, 429)
57
42
  }
58
43
 
59
- // Find user by email. We always return 200 success even if the user is not found to prevent user enumeration.
60
- const registeredUser = await env.DB
61
- .prepare('SELECT id FROM users WHERE email = ?')
62
- .bind(normalizedEmail)
63
- .first<{ id: string }>()
64
-
44
+ // Always return 200 even when the user is not found to prevent user enumeration.
45
+ const registeredUser = await context.get('userRepository').findByEmail(normalizedEmail)
65
46
  if (!registeredUser) {
66
47
  return context.json({ success: true })
67
48
  }
68
49
 
69
- // Invalidate any existing pending tokens for the same user before issuing a new one.
70
- await env.DB
71
- .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
72
- .bind(registeredUser.id)
73
- .run()
50
+ const nowTimestamp = Math.floor(Date.now() / 1000)
51
+ await context.get('passwordResetTokenRepository').invalidatePending(registeredUser.id, nowTimestamp)
74
52
 
75
53
  const resetToken = crypto.randomUUID()
76
- const hashedResetToken = await computeSha256Hash(resetToken)
77
- const expirationTimestamp = Math.floor(Date.now() / 1000) + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
54
+ const tokenHash = await sha256hex(resetToken)
55
+ const expiresAt = nowTimestamp + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
78
56
 
79
- // Store the hashed token in the database
80
- await env.DB
81
- .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
82
- .bind(crypto.randomUUID(), registeredUser.id, hashedResetToken, expirationTimestamp)
83
- .run()
57
+ await context.get('passwordResetTokenRepository').create({
58
+ userId: registeredUser.id,
59
+ tokenHash,
60
+ expiresAt,
61
+ })
84
62
 
85
63
  const baseUrl = (env.APP_URL ?? new URL(req.url).origin).replace(/\/$/, '')
86
64
  const resetUrl = `${baseUrl}/admin/reset-password?token=${resetToken}`
@@ -95,7 +73,6 @@ export async function requestPasswordReset(
95
73
  isDev: env.ENV !== 'production',
96
74
  })
97
75
  } catch (error) {
98
- // Only log errors in non-production environments
99
76
  if (env.ENV !== 'production') {
100
77
  console.error('[password-reset] Failed to send email:', error)
101
78
  }
@@ -103,4 +80,3 @@ export async function requestPasswordReset(
103
80
 
104
81
  return context.json({ success: true })
105
82
  }
106
-
@@ -1,25 +1,13 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import type { Context } from 'hono'
3
- import bcrypt from 'bcryptjs'
4
3
  import type { Env, Variables } from '../../types'
5
4
  import { sendPasswordChangedEmail, resolveEmailLocale } from '../email'
5
+ import { sha256hex } from '@beechcms/core'
6
+ import { getClientIp } from '../../shared/request-utils'
6
7
 
7
8
  const MIN_PASSWORD_LENGTH = 8
8
9
  const MAX_PASSWORD_LENGTH = 128
9
10
 
10
- /**
11
- * Computes the SHA-256 hash of a string and returns it as a hex string.
12
- */
13
- async function computeSha256Hash(text: string): Promise<string> {
14
- const encoder = new TextEncoder()
15
- const data = encoder.encode(text)
16
- const hashBuffer = await crypto.subtle.digest('SHA-256', data)
17
-
18
- return Array.from(new Uint8Array(hashBuffer))
19
- .map(byte => byte.toString(16).padStart(2, '0'))
20
- .join('')
21
- }
22
-
23
11
  /**
24
12
  * Handles the actual password reset process using a valid token.
25
13
  */
@@ -32,14 +20,10 @@ export async function resetPassword(
32
20
  return context.json({ error: 'Service not available' }, 503)
33
21
  }
34
22
 
35
- // Rate limiting: e.g., 5 attempts per IP per 60 seconds
36
- if (env.RESET_PASSWORD_RATE_LIMITER) {
37
- const clientIpAddress = req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
38
- const { success: isRateLimitAllowed } = await env.RESET_PASSWORD_RATE_LIMITER.limit({ key: clientIpAddress })
39
-
40
- if (!isRateLimitAllowed) {
41
- return context.json({ error: 'Too many requests' }, 429)
42
- }
23
+ const clientIpAddress = getClientIp(req)
24
+ const resetPasswordRateLimit = await context.get('rateLimiters').getLimiter('resetPassword').checkLimit(clientIpAddress)
25
+ if (!resetPasswordRateLimit.isAllowed) {
26
+ return context.json({ error: 'Too many requests' }, 429)
43
27
  }
44
28
 
45
29
  let payload: Record<string, unknown>
@@ -55,55 +39,36 @@ export async function resetPassword(
55
39
  }
56
40
 
57
41
  const newPassword = payload.password
58
- if (
42
+ const isPasswordInvalid =
59
43
  typeof newPassword !== 'string' ||
60
44
  newPassword.length < MIN_PASSWORD_LENGTH ||
61
45
  newPassword.length > MAX_PASSWORD_LENGTH
62
- ) {
46
+
47
+ if (isPasswordInvalid) {
63
48
  return context.json({
64
49
  error: `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters`,
65
50
  }, 400)
66
51
  }
67
52
 
68
53
  const emailLocale = resolveEmailLocale(payload.locale)
69
- const hashedResetToken = await computeSha256Hash(resetToken)
70
- const currentTimestamp = Math.floor(Date.now() / 1000)
54
+ const tokenHash = await sha256hex(resetToken as string)
55
+ const nowTimestamp = Math.floor(Date.now() / 1000)
71
56
 
72
- // JOIN with users table to retrieve the email in a single query - needed for notification.
73
- const resetTokenRecord = await env.DB
74
- .prepare(
75
- `SELECT prt.id, prt.user_id, u.email
76
- FROM password_reset_tokens prt
77
- JOIN users u ON u.id = prt.user_id
78
- WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`,
79
- )
80
- .bind(hashedResetToken, currentTimestamp)
81
- .first<{ id: string; user_id: string; email: string }>()
82
-
83
- if (!resetTokenRecord) {
57
+ const tokenRecord = await context.get('passwordResetTokenRepository').findValidByHashWithEmail(tokenHash, nowTimestamp)
58
+ if (!tokenRecord) {
84
59
  return context.json({ error: 'Invalid or expired token' }, 400)
85
60
  }
86
61
 
87
- const hashedNewPassword = await bcrypt.hash(newPassword, 10)
62
+ const hashedNewPassword = await context.get('hashProvider').hash(newPassword as string)
88
63
 
89
- // Mark token as used, update user password, and revoke all active sessions - atomically.
90
- await env.DB.batch([
91
- env.DB
92
- .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE id = ?')
93
- .bind(resetTokenRecord.id),
94
- env.DB
95
- .prepare('UPDATE users SET password_hash = ? WHERE id = ?')
96
- .bind(hashedNewPassword, resetTokenRecord.user_id),
97
- env.DB
98
- .prepare('UPDATE refresh_tokens SET revoked_at = unixepoch() WHERE user_id = ? AND revoked_at IS NULL')
99
- .bind(resetTokenRecord.user_id),
100
- ])
64
+ await context.get('passwordResetTokenRepository').markUsed(tokenRecord.id, nowTimestamp)
65
+ await context.get('userRepository').updatePasswordHash(tokenRecord.userId, hashedNewPassword)
66
+ await context.get('sessionRepository').revokeAllForUser(tokenRecord.userId, nowTimestamp)
101
67
 
102
- // Notify user that password was changed - fire-and-forget via waitUntil, doesn't block response.
103
68
  const sendNotification = async () => {
104
69
  try {
105
70
  await sendPasswordChangedEmail({
106
- to: resetTokenRecord.email,
71
+ to: tokenRecord.email,
107
72
  locale: emailLocale,
108
73
  apiKey: env.RESEND_API_KEY!,
109
74
  from: env.EMAIL_FROM,
@@ -125,4 +90,3 @@ export async function resetPassword(
125
90
 
126
91
  return context.json({ success: true })
127
92
  }
128
-
@@ -1,6 +1,6 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
- import { resolvePolicies, verifyHashField, sha256hex, validateAndSanitizeSeedPayload, serializeForDb } from '@beechcms/core'
3
+ import { resolvePolicies, verifyHashField, sha256hex, validateAndSanitizeSeedPayload, EntryNotFoundError } from '@beechcms/core'
4
4
  import { publicProblem } from '../../public/problem-details'
5
5
  import { rotateFieldRequestSchema } from './rotate-field.schema'
6
6
  import type { Env, Variables } from '../../types'
@@ -65,20 +65,19 @@ rotateFieldApp.post('/:slug/:id/rotate-field', async (context) => {
65
65
  })
66
66
  }
67
67
 
68
- const database = context.env.DB
69
-
70
- // In v0.4.0, the hash value is stored in a dedicated column: contentRecord[targetFieldBranch.alias]
71
- const contentRecord = await database.prepare(`SELECT ${targetFieldBranch.alias} FROM content_${seedSlug} WHERE id = ? LIMIT 1`)
72
- .bind(entryId)
73
- .first<Record<string, unknown>>()
74
-
75
- if (!contentRecord) {
76
- return publicProblem(context, {
77
- type: 'content-not-found',
78
- title: 'Not Found',
79
- status: 404,
80
- detail: `Entry '${entryId}' not found`
81
- })
68
+ let contentRecord: Record<string, unknown>
69
+ try {
70
+ contentRecord = await context.get('repository').findById(seed, entryId)
71
+ } catch (error) {
72
+ if (error instanceof EntryNotFoundError) {
73
+ return publicProblem(context, {
74
+ type: 'content-not-found',
75
+ title: 'Not Found',
76
+ status: 404,
77
+ detail: `Entry '${entryId}' not found`
78
+ })
79
+ }
80
+ throw error
82
81
  }
83
82
 
84
83
  const storedFieldValueHash = contentRecord[targetFieldBranch.alias]
@@ -118,11 +117,8 @@ rotateFieldApp.post('/:slug/:id/rotate-field', async (context) => {
118
117
  }
119
118
 
120
119
  const newFieldValueHash = await sha256hex(nextValue)
121
- const currentTimestamp = Math.floor(Date.now() / 1000)
122
120
 
123
- await database.prepare(`UPDATE content_${seedSlug} SET ${targetFieldBranch.alias} = ?, updated_at = ? WHERE id = ?`)
124
- .bind(serializeForDb(targetFieldBranch, newFieldValueHash), currentTimestamp, entryId)
125
- .run()
121
+ await context.get('repository').update(seed, entryId, { [targetFieldBranch.alias]: newFieldValueHash })
126
122
 
127
123
  return context.json({ success: true })
128
124
  })
@@ -10,7 +10,7 @@ const schemaApp = new Hono<{ Bindings: Env; Variables: Variables }>()
10
10
  */
11
11
  schemaApp.get('/', async (context) => {
12
12
  const registry = context.get('seedRegistry')
13
- return context.json(Object.values(registry))
13
+ return context.json(registry.all())
14
14
  })
15
15
 
16
16
  export { schemaApp }