@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.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +10 -10
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +74 -91
- package/src/auth/refresh.ts +5 -127
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +339 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +157 -0
- package/src/features/content/handlers/delete.ts +80 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +211 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +283 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +101 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +82 -88
- package/src/features/password-reset/reset.ts +92 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +128 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +301 -249
- package/src/features/setup/index.ts +91 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +430 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +60 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +47 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +156 -183
- package/src/public/public-edit.ts +159 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +30 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +209 -192
- package/src/search.ts +61 -72
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +63 -36
- package/src/upload.ts +186 -335
- package/src/widget.ts +208 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
- package/src/shared/activity-logger.ts +0 -79
- 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', (
|
|
11
|
-
return
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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,
|
|
4
|
-
import { publicProblem } from '../../public/problem-details'
|
|
5
|
-
import {
|
|
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 (
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
const seed =
|
|
15
|
-
if (!seed) {
|
|
16
|
-
return publicProblem(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return publicProblem(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!
|
|
50
|
-
return publicProblem(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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>
|