@beechcms/api 0.4.0-preview.9 → 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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
@@ -1,15 +1,15 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import type { Env, Variables } from '../../types'
4
- import { requestPasswordReset } from './request'
5
- import { resetPassword } from './reset'
6
-
7
- export const passwordResetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
-
9
- // Feature flag: lets the dashboard know whether to show the forgot-password link
10
- passwordResetApp.get('/auth/features', (c) => {
11
- return c.json({ passwordReset: Boolean(c.env.RESEND_API_KEY) })
12
- })
13
-
14
- passwordResetApp.post('/auth/forgot-password', requestPasswordReset)
15
- passwordResetApp.post('/auth/reset-password', resetPassword)
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+ import { requestPasswordReset } from './request'
5
+ import { resetPassword } from './reset'
6
+
7
+ export const passwordResetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
+
9
+ // Feature flag: lets the dashboard know whether to show the forgot-password link
10
+ passwordResetApp.get('/auth/features', (context) => {
11
+ return context.json({ passwordReset: Boolean(context.env.RESEND_API_KEY) })
12
+ })
13
+
14
+ passwordResetApp.post('/auth/forgot-password', requestPasswordReset)
15
+ passwordResetApp.post('/auth/reset-password', resetPassword)
@@ -1,88 +1,82 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type { Context } from 'hono'
3
- import type { Env, Variables } from '../../types'
4
- import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
5
-
6
- const TOKEN_EXPIRY_SECONDS = 30 * 60
7
-
8
- async function sha256hex(text: string): Promise<string> {
9
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
10
- return Array.from(new Uint8Array(buf))
11
- .map(b => b.toString(16).padStart(2, '0'))
12
- .join('')
13
- }
14
-
15
- export async function requestPasswordReset(
16
- c: Context<{ Bindings: Env; Variables: Variables }>
17
- ): Promise<Response> {
18
- if (!c.env.RESEND_API_KEY) {
19
- return c.json({ error: 'Not available' }, 503)
20
- }
21
-
22
- let body: Record<string, unknown>
23
- try {
24
- body = await c.req.json()
25
- } catch {
26
- return c.json({ error: 'Invalid request body' }, 400)
27
- }
28
-
29
- if (typeof body.email !== 'string' || !body.email.trim()) {
30
- return c.json({ error: 'Invalid request' }, 400)
31
- }
32
-
33
- const email = body.email.trim().toLowerCase()
34
- const locale = resolveEmailLocale(body.locale)
35
-
36
- if (c.env.FORGOT_PASSWORD_RATE_LIMITER) {
37
- const ip = c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
38
- const { success } = await c.env.FORGOT_PASSWORD_RATE_LIMITER.limit({ key: ip })
39
- if (!success) {
40
- return c.json({ error: 'Too many requests' }, 429)
41
- }
42
- }
43
-
44
- // Sempre 200 per evitare user enumeration
45
- const user = await c.env.DB
46
- .prepare('SELECT id FROM users WHERE email = ?')
47
- .bind(email)
48
- .first<{ id: string }>()
49
-
50
- if (!user) {
51
- return c.json({ success: true })
52
- }
53
-
54
- // Invalida eventuali token pendenti per lo stesso utente prima di emetterne uno nuovo
55
- await c.env.DB
56
- .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
57
- .bind(user.id)
58
- .run()
59
-
60
- const token = crypto.randomUUID()
61
- const tokenHash = await sha256hex(token)
62
- const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS
63
-
64
- await c.env.DB
65
- .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
66
- .bind(crypto.randomUUID(), user.id, tokenHash, expiresAt)
67
- .run()
68
-
69
- const appUrl = (c.env.APP_URL ?? new URL(c.req.url).origin).replace(/\/$/, '')
70
- const resetUrl = `${appUrl}/admin/reset-password?token=${token}`
71
-
72
- try {
73
- await sendPasswordResetEmail({
74
- to: email,
75
- resetUrl,
76
- locale,
77
- apiKey: c.env.RESEND_API_KEY,
78
- from: c.env.EMAIL_FROM,
79
- isDev: c.env.ENV !== 'production',
80
- })
81
- } catch (err) {
82
- if (c.env.ENV !== 'production') {
83
- console.error('[password-reset] invio email fallito:', err)
84
- }
85
- }
86
-
87
- return c.json({ success: true })
88
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Context } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+ import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
5
+ import { sha256hex } from '@beechcms/core'
6
+ import { getClientIp } from '../../shared/request-utils'
7
+
8
+ const PASSWORD_RESET_TOKEN_EXPIRY_SECONDS = 30 * 60
9
+
10
+ /**
11
+ * Handles the password reset request.
12
+ * Generates a reset token, stores its hash in the database, and sends an email to the user.
13
+ */
14
+ export async function requestPasswordReset(
15
+ context: Context<{ Bindings: Env; Variables: Variables }>
16
+ ): Promise<Response> {
17
+ const { env, req } = context
18
+
19
+ if (!env.RESEND_API_KEY) {
20
+ return context.json({ error: 'Service not available' }, 503)
21
+ }
22
+
23
+ let payload: Record<string, unknown>
24
+ try {
25
+ payload = await req.json()
26
+ } catch {
27
+ return context.json({ error: 'Invalid request body' }, 400)
28
+ }
29
+
30
+ const emailInput = payload.email
31
+ if (typeof emailInput !== 'string' || !emailInput.trim()) {
32
+ return context.json({ error: 'Invalid request' }, 400)
33
+ }
34
+
35
+ const normalizedEmail = emailInput.trim().toLowerCase()
36
+ const emailLocale = resolveEmailLocale(payload.locale)
37
+
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)
42
+ }
43
+
44
+ // Always return 200 even when the user is not found to prevent user enumeration.
45
+ const registeredUser = await context.get('userRepository').findByEmail(normalizedEmail)
46
+ if (!registeredUser) {
47
+ return context.json({ success: true })
48
+ }
49
+
50
+ const nowTimestamp = Math.floor(Date.now() / 1000)
51
+ await context.get('passwordResetTokenRepository').invalidatePending(registeredUser.id, nowTimestamp)
52
+
53
+ const resetToken = crypto.randomUUID()
54
+ const tokenHash = await sha256hex(resetToken)
55
+ const expiresAt = nowTimestamp + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
56
+
57
+ await context.get('passwordResetTokenRepository').create({
58
+ userId: registeredUser.id,
59
+ tokenHash,
60
+ expiresAt,
61
+ })
62
+
63
+ const baseUrl = (env.APP_URL ?? new URL(req.url).origin).replace(/\/$/, '')
64
+ const resetUrl = `${baseUrl}/admin/reset-password?token=${resetToken}`
65
+
66
+ try {
67
+ await sendPasswordResetEmail({
68
+ to: normalizedEmail,
69
+ resetUrl,
70
+ locale: emailLocale,
71
+ apiKey: env.RESEND_API_KEY,
72
+ from: env.EMAIL_FROM,
73
+ isDev: env.ENV !== 'production',
74
+ })
75
+ } catch (error) {
76
+ if (env.ENV !== 'production') {
77
+ console.error('[password-reset] Failed to send email:', error)
78
+ }
79
+ }
80
+
81
+ return context.json({ success: true })
82
+ }
@@ -1,110 +1,92 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type { Context } from 'hono'
3
- import bcrypt from 'bcryptjs'
4
- import type { Env, Variables } from '../../types'
5
- import { sendPasswordChangedEmail, resolveEmailLocale } from '../email'
6
-
7
- const MIN_PASSWORD_LENGTH = 8
8
- const MAX_PASSWORD_LENGTH = 128
9
-
10
- async function sha256hex(text: string): Promise<string> {
11
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
12
- return Array.from(new Uint8Array(buf))
13
- .map(b => b.toString(16).padStart(2, '0'))
14
- .join('')
15
- }
16
-
17
- export async function resetPassword(
18
- c: Context<{ Bindings: Env; Variables: Variables }>
19
- ): Promise<Response> {
20
- if (!c.env.RESEND_API_KEY) {
21
- return c.json({ error: 'Not available' }, 503)
22
- }
23
-
24
- // Rate limiting: 5 tentativi per IP per 60 secondi
25
- if (c.env.RESET_PASSWORD_RATE_LIMITER) {
26
- const ip = c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
27
- const { success } = await c.env.RESET_PASSWORD_RATE_LIMITER.limit({ key: ip })
28
- if (!success) {
29
- return c.json({ error: 'Too many requests' }, 429)
30
- }
31
- }
32
-
33
- let body: Record<string, unknown>
34
- try {
35
- body = await c.req.json()
36
- } catch {
37
- return c.json({ error: 'Invalid request body' }, 400)
38
- }
39
-
40
- if (typeof body.token !== 'string' || !body.token) {
41
- return c.json({ error: 'Invalid or expired token' }, 400)
42
- }
43
-
44
- if (
45
- typeof body.password !== 'string' ||
46
- body.password.length < MIN_PASSWORD_LENGTH ||
47
- body.password.length > MAX_PASSWORD_LENGTH
48
- ) {
49
- return c.json({
50
- error: `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters`,
51
- }, 400)
52
- }
53
-
54
- const locale = resolveEmailLocale(body.locale)
55
- const tokenHash = await sha256hex(body.token)
56
- const now = Math.floor(Date.now() / 1000)
57
-
58
- // JOIN users per recuperare l'email in un'unica query — serve per la notifica
59
- const record = await c.env.DB
60
- .prepare(
61
- `SELECT prt.id, prt.user_id, u.email
62
- FROM password_reset_tokens prt
63
- JOIN users u ON u.id = prt.user_id
64
- WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`,
65
- )
66
- .bind(tokenHash, now)
67
- .first<{ id: string; user_id: string; email: string }>()
68
-
69
- if (!record) {
70
- return c.json({ error: 'Invalid or expired token' }, 400)
71
- }
72
-
73
- const newHash = await bcrypt.hash(body.password, 10)
74
-
75
- // Segna token usato, aggiorna password, revoca tutte le sessioni — atomicamente
76
- await c.env.DB.batch([
77
- c.env.DB
78
- .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE id = ?')
79
- .bind(record.id),
80
- c.env.DB
81
- .prepare('UPDATE users SET password_hash = ? WHERE id = ?')
82
- .bind(newHash, record.user_id),
83
- c.env.DB
84
- .prepare('UPDATE refresh_tokens SET revoked_at = unixepoch() WHERE user_id = ? AND revoked_at IS NULL')
85
- .bind(record.user_id),
86
- ])
87
-
88
- // Notifica "password modificata" — fire-and-forget via waitUntil, non blocca il 200
89
- const notify = () =>
90
- sendPasswordChangedEmail({
91
- to: record.email,
92
- locale,
93
- apiKey: c.env.RESEND_API_KEY!,
94
- from: c.env.EMAIL_FROM,
95
- isDev: c.env.ENV !== 'production',
96
- }).catch((err) => {
97
- if (c.env.ENV !== 'production') {
98
- console.error('[password-reset] notifica email fallita:', err)
99
- }
100
- })
101
-
102
- try {
103
- c.executionCtx.waitUntil(notify())
104
- } catch {
105
- // executionCtx non disponibile in ambiente di test
106
- void notify()
107
- }
108
-
109
- return c.json({ success: true })
110
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Context } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+ import { sendPasswordChangedEmail, resolveEmailLocale } from '../email'
5
+ import { sha256hex } from '@beechcms/core'
6
+ import { getClientIp } from '../../shared/request-utils'
7
+
8
+ const MIN_PASSWORD_LENGTH = 8
9
+ const MAX_PASSWORD_LENGTH = 128
10
+
11
+ /**
12
+ * Handles the actual password reset process using a valid token.
13
+ */
14
+ export async function resetPassword(
15
+ context: Context<{ Bindings: Env; Variables: Variables }>
16
+ ): Promise<Response> {
17
+ const { env, req, executionCtx } = context
18
+
19
+ if (!env.RESEND_API_KEY) {
20
+ return context.json({ error: 'Service not available' }, 503)
21
+ }
22
+
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)
27
+ }
28
+
29
+ let payload: Record<string, unknown>
30
+ try {
31
+ payload = await req.json()
32
+ } catch {
33
+ return context.json({ error: 'Invalid request body' }, 400)
34
+ }
35
+
36
+ const resetToken = payload.token
37
+ if (typeof resetToken !== 'string' || !resetToken) {
38
+ return context.json({ error: 'Invalid or expired token' }, 400)
39
+ }
40
+
41
+ const newPassword = payload.password
42
+ const isPasswordInvalid =
43
+ typeof newPassword !== 'string' ||
44
+ newPassword.length < MIN_PASSWORD_LENGTH ||
45
+ newPassword.length > MAX_PASSWORD_LENGTH
46
+
47
+ if (isPasswordInvalid) {
48
+ return context.json({
49
+ error: `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters`,
50
+ }, 400)
51
+ }
52
+
53
+ const emailLocale = resolveEmailLocale(payload.locale)
54
+ const tokenHash = await sha256hex(resetToken as string)
55
+ const nowTimestamp = Math.floor(Date.now() / 1000)
56
+
57
+ const tokenRecord = await context.get('passwordResetTokenRepository').findValidByHashWithEmail(tokenHash, nowTimestamp)
58
+ if (!tokenRecord) {
59
+ return context.json({ error: 'Invalid or expired token' }, 400)
60
+ }
61
+
62
+ const hashedNewPassword = await context.get('hashProvider').hash(newPassword as string)
63
+
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)
67
+
68
+ const sendNotification = async () => {
69
+ try {
70
+ await sendPasswordChangedEmail({
71
+ to: tokenRecord.email,
72
+ locale: emailLocale,
73
+ apiKey: env.RESEND_API_KEY!,
74
+ from: env.EMAIL_FROM,
75
+ isDev: env.ENV !== 'production',
76
+ })
77
+ } catch (error) {
78
+ if (env.ENV !== 'production') {
79
+ console.error('[password-reset] Failed to send password change notification:', error)
80
+ }
81
+ }
82
+ }
83
+
84
+ try {
85
+ executionCtx.waitUntil(sendNotification())
86
+ } catch {
87
+ // executionCtx might not be available in some testing environments
88
+ void sendNotification()
89
+ }
90
+
91
+ return context.json({ success: true })
92
+ }
@@ -1 +1 @@
1
- export { rotateFieldApp } from './rotate-field.handler'
1
+ export { rotateFieldApp } from './rotate-field.handler'
@@ -1,82 +1,128 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import { resolvePolicies, verifyHashField, sha256hex, validateAndSanitizeSeedPayload, serializeForDb } from '@beechcms/core'
4
- import { publicProblem } from '../../public/problem-details'
5
- import { rotateFieldBodySchema } from './rotate-field.schema'
6
- import type { Env, Variables } from '../../types'
7
-
8
- const rotateFieldApp = new Hono<{ Bindings: Env; Variables: Variables }>()
9
-
10
- rotateFieldApp.post('/:slug/:id/rotate-field', async (c) => {
11
- const slug = c.req.param('slug')
12
- const id = c.req.param('id')
13
-
14
- const seed = c.get('getSeed')(slug)
15
- if (!seed) {
16
- return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: `Seed '${slug}' not found` })
17
- }
18
-
19
- let rawBody: unknown
20
- try {
21
- rawBody = await c.req.json()
22
- } catch {
23
- return publicProblem(c, { type: 'rotate-field-invalid-json', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
24
- }
25
-
26
- const parsed = rotateFieldBodySchema.safeParse(rawBody)
27
- if (!parsed.success) {
28
- return publicProblem(c, { type: 'rotate-field-invalid-body', title: 'Bad Request', status: 400, detail: parsed.error.issues[0]?.message ?? 'Invalid body' })
29
- }
30
-
31
- const { field, current, next } = parsed.data
32
-
33
- const branch = seed.branches.find((b) => b.alias === field)
34
- if (!branch) {
35
- return publicProblem(c, { type: 'rotate-field-unknown-field', title: 'Bad Request', status: 400, detail: `Field '${field}' does not exist in seed '${slug}'` })
36
- }
37
-
38
- const { privacy } = resolvePolicies(branch)
39
- if (privacy !== 'hash') {
40
- return publicProblem(c, { type: 'rotate-field-not-hashable', title: 'Unprocessable Entity', status: 422, detail: `Field '${field}' does not use hash privacy and cannot be rotated with this endpoint` })
41
- }
42
-
43
- const { DB } = c.env
44
- // In v0.4.0 il valore hash è in una colonna reale: row[branch.alias]
45
- const row = await DB.prepare(`SELECT ${branch.alias} FROM content_${slug} WHERE id = ? LIMIT 1`)
46
- .bind(id)
47
- .first<Record<string, unknown>>()
48
-
49
- if (!row) {
50
- return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found` })
51
- }
52
-
53
- const storedHash = row[branch.alias]
54
-
55
- if (typeof storedHash !== 'string' || storedHash.length === 0) {
56
- return publicProblem(c, { type: 'rotate-field-not-set', title: 'Unprocessable Entity', status: 422, detail: `Field '${field}' has no stored value to rotate` })
57
- }
58
-
59
- const matches = await verifyHashField(storedHash, current)
60
- if (!matches) {
61
- return publicProblem(c, { type: 'rotate-field-current-mismatch', title: 'Forbidden', status: 403, detail: 'Current value does not match stored value' })
62
- }
63
-
64
- const validation = validateAndSanitizeSeedPayload(
65
- seed, { [field]: next },
66
- { operation: 'update', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: false }
67
- )
68
- if (validation.details.length > 0) {
69
- return publicProblem(c, { type: 'rotate-field-invalid-next', title: 'Bad Request', status: 400, detail: `Invalid value for field '${field}': ${validation.details[0]?.message ?? 'validation failed'}` })
70
- }
71
-
72
- const nextHash = await sha256hex(next)
73
- const now = Math.floor(Date.now() / 1000)
74
-
75
- await DB.prepare(`UPDATE content_${slug} SET ${branch.alias} = ?, updated_at = ? WHERE id = ?`)
76
- .bind(serializeForDb(branch, nextHash), now, id)
77
- .run()
78
-
79
- return c.json({ success: true })
80
- })
81
-
82
- export { rotateFieldApp }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import { resolvePolicies, verifyHashField, sha256hex, validateAndSanitizeSeedPayload, EntryNotFoundError } from '@beechcms/core'
4
+ import { publicProblem } from '../../public/problem-details'
5
+ import { rotateFieldRequestSchema } from './rotate-field.schema'
6
+ import type { Env, Variables } from '../../types'
7
+
8
+ const rotateFieldApp = new Hono<{ Bindings: Env; Variables: Variables }>()
9
+
10
+ rotateFieldApp.post('/:slug/:id/rotate-field', async (context) => {
11
+ const seedSlug = context.req.param('slug')
12
+ const entryId = context.req.param('id')
13
+
14
+ const seed = context.get('getSeed')(seedSlug)
15
+ if (!seed) {
16
+ return publicProblem(context, {
17
+ type: 'content-seed-not-found',
18
+ title: 'Not Found',
19
+ status: 404,
20
+ detail: `Seed '${seedSlug}' not found`
21
+ })
22
+ }
23
+
24
+ let requestBody: unknown
25
+ try {
26
+ requestBody = await context.req.json()
27
+ } catch {
28
+ return publicProblem(context, {
29
+ type: 'rotate-field-invalid-json',
30
+ title: 'Bad Request',
31
+ status: 400,
32
+ detail: 'Invalid JSON body'
33
+ })
34
+ }
35
+
36
+ const parsedRequestBody = rotateFieldRequestSchema.safeParse(requestBody)
37
+ if (!parsedRequestBody.success) {
38
+ return publicProblem(context, {
39
+ type: 'rotate-field-invalid-body',
40
+ title: 'Bad Request',
41
+ status: 400,
42
+ detail: parsedRequestBody.error.issues[0]?.message ?? 'Invalid body'
43
+ })
44
+ }
45
+
46
+ const { fieldAlias, currentValue, nextValue } = parsedRequestBody.data
47
+
48
+ const targetFieldBranch = seed.branches.find((branch) => branch.alias === fieldAlias)
49
+ if (!targetFieldBranch) {
50
+ return publicProblem(context, {
51
+ type: 'rotate-field-unknown-field',
52
+ title: 'Bad Request',
53
+ status: 400,
54
+ detail: `Field '${fieldAlias}' does not exist in seed '${seedSlug}'`
55
+ })
56
+ }
57
+
58
+ const { privacy } = resolvePolicies(targetFieldBranch)
59
+ if (privacy !== 'hash') {
60
+ return publicProblem(context, {
61
+ type: 'rotate-field-not-hashable',
62
+ title: 'Unprocessable Entity',
63
+ status: 422,
64
+ detail: `Field '${fieldAlias}' does not use hash privacy and cannot be rotated with this endpoint`
65
+ })
66
+ }
67
+
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
81
+ }
82
+
83
+ const storedFieldValueHash = contentRecord[targetFieldBranch.alias]
84
+
85
+ if (typeof storedFieldValueHash !== 'string' || storedFieldValueHash.length === 0) {
86
+ return publicProblem(context, {
87
+ type: 'rotate-field-not-set',
88
+ title: 'Unprocessable Entity',
89
+ status: 422,
90
+ detail: `Field '${fieldAlias}' has no stored value to rotate`
91
+ })
92
+ }
93
+
94
+ const isCurrentValueValid = await verifyHashField(storedFieldValueHash, currentValue)
95
+ if (!isCurrentValueValid) {
96
+ return publicProblem(context, {
97
+ type: 'rotate-field-current-mismatch',
98
+ title: 'Forbidden',
99
+ status: 403,
100
+ detail: 'Current value does not match stored value'
101
+ })
102
+ }
103
+
104
+ const fieldValidationResult = validateAndSanitizeSeedPayload(
105
+ seed,
106
+ { [fieldAlias]: nextValue },
107
+ { operation: 'update', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: false }
108
+ )
109
+
110
+ if (fieldValidationResult.details.length > 0) {
111
+ return publicProblem(context, {
112
+ type: 'rotate-field-invalid-next',
113
+ title: 'Bad Request',
114
+ status: 400,
115
+ detail: `Invalid value for field '${fieldAlias}': ${fieldValidationResult.details[0]?.message ?? 'validation failed'}`
116
+ })
117
+ }
118
+
119
+ const newFieldValueHash = await sha256hex(nextValue)
120
+
121
+ await context.get('repository').update(seed, entryId, { [targetFieldBranch.alias]: newFieldValueHash })
122
+
123
+ return context.json({ success: true })
124
+ })
125
+
126
+
127
+ export { rotateFieldApp }
128
+
@@ -1,9 +1,13 @@
1
- import { z } from 'zod'
2
-
3
- export const rotateFieldBodySchema = z.object({
4
- field: z.string().min(1, "field is required"),
5
- current: z.string().min(1, "current is required"),
6
- next: z.string().min(1, "next is required"),
7
- })
8
-
9
- export type RotateFieldBody = z.infer<typeof rotateFieldBodySchema>
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Schema for rotating a hashed field value.
5
+ * Used to update fields that have 'hash' privacy (e.g., passwords, PINs).
6
+ */
7
+ export const rotateFieldRequestSchema = z.object({
8
+ fieldAlias: z.string().min(1, "The field alias is required"),
9
+ currentValue: z.string().min(1, "The current value is required"),
10
+ nextValue: z.string().min(1, "The new value is required"),
11
+ })
12
+
13
+ export type RotateFieldRequest = z.infer<typeof rotateFieldRequestSchema>