@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.
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/index.html +1 -1
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- 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 +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +70 -78
- package/src/features/content/handlers/create.ts +14 -10
- package/src/features/content/handlers/delete.ts +13 -9
- package/src/features/content/handlers/update.ts +13 -9
- package/src/features/draft/draft.handler.ts +23 -12
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +62 -175
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- 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 +41 -5
- package/src/middleware.ts +15 -35
- package/src/public/public-add.ts +5 -15
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +3 -3
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- 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 +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -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/idempotency.repository.d1.test.ts +79 -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 +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +20 -3
- package/src/upload.ts +14 -7
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
//
|
|
60
|
-
const registeredUser = await
|
|
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
|
-
|
|
70
|
-
await
|
|
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
|
|
77
|
-
const
|
|
54
|
+
const tokenHash = await sha256hex(resetToken)
|
|
55
|
+
const expiresAt = nowTimestamp + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
|
|
78
56
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
const
|
|
54
|
+
const tokenHash = await sha256hex(resetToken as string)
|
|
55
|
+
const nowTimestamp = Math.floor(Date.now() / 1000)
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
62
|
+
const hashedNewPassword = await context.get('hashProvider').hash(newPassword as string)
|
|
88
63
|
|
|
89
|
-
|
|
90
|
-
await
|
|
91
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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(
|
|
13
|
+
return context.json(registry.all())
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
export { schemaApp }
|