@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
@@ -1,6 +1,5 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
- import bcrypt from 'bcryptjs'
4
3
  import type { Env, Variables } from '../../types'
5
4
 
6
5
  const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
@@ -8,48 +7,14 @@ const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
7
  const EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
9
8
  const MIN_PASSWORD_LENGTH = 8
10
9
  const MAX_PASSWORD_LENGTH = 128
11
- const BCRYPT_SALT_ROUNDS = 10
12
-
13
- type UserRow = {
14
- id: string
15
- email: string
16
- name: string | null
17
- avatar_url: string | null
18
- password_hash: string
19
- notification_prefs: string
20
- }
21
-
22
- type SessionRow = {
23
- id: string
24
- created_at: number
25
- expires_at: number
26
- }
27
-
28
- type ActivityRow = {
29
- id: string
30
- action: string
31
- entity_type: string
32
- entity_slug: string | null
33
- details: string | null
34
- created_at: number
35
- }
36
-
37
- type MediaFileRow = {
38
- key: string
39
- filename: string
40
- mime_type: string
41
- size_bytes: number
42
- created_at: number
43
- }
44
-
10
+ const SESSION_LIST_LIMIT = 20
11
+ const ACTIVITY_LOG_LIMIT = 30
45
12
 
46
13
  /**
47
14
  * GET /api/settings
48
15
  * Retrieves the general site configuration.
49
16
  */
50
17
  settingsApp.get('/', async (context) => {
51
- // General site configuration.
52
- // In the future, these could be loaded from a 'system_settings' table in D1.
53
18
  return context.json({
54
19
  siteTitle: 'Beech CMS',
55
20
  siteLogo: '/beechLogoDark.svg',
@@ -70,18 +35,15 @@ settingsApp.get('/', async (context) => {
70
35
  */
71
36
  settingsApp.get('/me', async (context) => {
72
37
  const { sub: userId } = context.get('jwtPayload')
73
-
74
- const currentUser = await context.env.DB.prepare(
75
- 'SELECT id, email, name, avatar_url, notification_prefs FROM users WHERE id = ? LIMIT 1'
76
- ).bind(userId).first<Omit<UserRow, 'password_hash'>>()
77
-
38
+
39
+ const currentUser = await context.get('userRepository').findById(userId)
78
40
  if (!currentUser) {
79
41
  return context.json({ error: 'User not found' }, 404)
80
42
  }
81
43
 
82
44
  let notificationPreferences: Record<string, boolean>
83
45
  try {
84
- notificationPreferences = JSON.parse(currentUser.notification_prefs || '{}')
46
+ notificationPreferences = JSON.parse(currentUser.notificationPreferences || '{}')
85
47
  } catch {
86
48
  notificationPreferences = {}
87
49
  }
@@ -90,7 +52,7 @@ settingsApp.get('/me', async (context) => {
90
52
  id: currentUser.id,
91
53
  email: currentUser.email,
92
54
  name: currentUser.name,
93
- avatarUrl: currentUser.avatar_url,
55
+ avatarUrl: currentUser.avatarUrl,
94
56
  notificationPrefs: {
95
57
  contentCreate: notificationPreferences.contentCreate ?? true,
96
58
  contentUpdate: notificationPreferences.contentUpdate ?? true,
@@ -106,7 +68,7 @@ settingsApp.get('/me', async (context) => {
106
68
  */
107
69
  settingsApp.put('/profile', async (context) => {
108
70
  const { sub: userId } = context.get('jwtPayload')
109
-
71
+
110
72
  let payload: Record<string, unknown>
111
73
  try {
112
74
  payload = await context.req.json()
@@ -118,61 +80,30 @@ settingsApp.put('/profile', async (context) => {
118
80
  const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
119
81
 
120
82
  if (emailInput !== null && !EMAIL_VALIDATION_REGEX.test(emailInput)) {
121
- return context.json({
122
- type: 'bad-request',
123
- title: 'Bad Request',
124
- status: 400,
125
- detail: 'Invalid email format'
126
- }, 400)
83
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid email format' }, 400)
127
84
  }
128
-
85
+
129
86
  if (nameInput !== null && nameInput.length > 100) {
130
- return context.json({
131
- type: 'bad-request',
132
- title: 'Bad Request',
133
- status: 400,
134
- detail: 'Name is too long (maximum 100 characters)'
135
- }, 400)
87
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Name is too long (maximum 100 characters)' }, 400)
136
88
  }
137
89
 
138
- const fieldsToUpdate: string[] = []
139
- const valuesToUpdate: unknown[] = []
140
-
141
- if (nameInput !== null) {
142
- fieldsToUpdate.push('name = ?')
143
- valuesToUpdate.push(nameInput)
144
- }
145
-
146
- if (emailInput !== null) {
147
- fieldsToUpdate.push('email = ?')
148
- valuesToUpdate.push(emailInput)
149
- }
150
-
151
- if (fieldsToUpdate.length === 0) {
90
+ const hasNoFields = nameInput === null && emailInput === null
91
+ if (hasNoFields) {
152
92
  return context.json({ error: 'No fields to update' }, 400)
153
93
  }
154
94
 
155
- // Check if the new email is already taken by another user
156
95
  if (emailInput !== null) {
157
- const existingUserWithEmail = await context.env.DB.prepare(
158
- 'SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1'
159
- ).bind(emailInput, userId).first()
160
-
161
- if (existingUserWithEmail) {
162
- return context.json({
163
- type: 'conflict',
164
- title: 'Conflict',
165
- status: 409,
166
- detail: 'Email address is already in use'
167
- }, 409)
96
+ const emailTaken = await context.get('userRepository').emailBelongsToAnotherUser(emailInput, userId)
97
+ if (emailTaken) {
98
+ return context.json({ type: 'conflict', title: 'Conflict', status: 409, detail: 'Email address is already in use' }, 409)
168
99
  }
169
100
  }
170
101
 
171
- valuesToUpdate.push(userId)
172
- await context.env.DB.prepare(
173
- `UPDATE users SET ${fieldsToUpdate.join(', ')} WHERE id = ?`
174
- ).bind(...valuesToUpdate).run()
175
-
102
+ const fieldsToUpdate: { name?: string; email?: string } = {}
103
+ if (nameInput !== null) fieldsToUpdate.name = nameInput
104
+ if (emailInput !== null) fieldsToUpdate.email = emailInput
105
+
106
+ await context.get('userRepository').updateProfile(userId, fieldsToUpdate)
176
107
  return context.json({ success: true })
177
108
  })
178
109
 
@@ -182,7 +113,7 @@ settingsApp.put('/profile', async (context) => {
182
113
  */
183
114
  settingsApp.put('/password', async (context) => {
184
115
  const { sub: userId } = context.get('jwtPayload')
185
-
116
+
186
117
  let payload: Record<string, unknown>
187
118
  try {
188
119
  payload = await context.req.json()
@@ -196,38 +127,26 @@ settingsApp.put('/password', async (context) => {
196
127
  if (!currentPassword || !newPassword) {
197
128
  return context.json({ error: 'Both currentPassword and newPassword are required' }, 400)
198
129
  }
199
-
200
130
  if (newPassword.length < MIN_PASSWORD_LENGTH) {
201
131
  return context.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }, 400)
202
132
  }
203
-
204
133
  if (newPassword.length > MAX_PASSWORD_LENGTH) {
205
134
  return context.json({ error: 'Password is too long' }, 400)
206
135
  }
207
136
 
208
- const userRecord = await context.env.DB.prepare(
209
- 'SELECT password_hash FROM users WHERE id = ? LIMIT 1'
210
- ).bind(userId).first<{ password_hash: string }>()
211
-
137
+ const userRecord = await context.get('userRepository').findById(userId)
212
138
  if (!userRecord) {
213
139
  return context.json({ error: 'User not found' }, 404)
214
140
  }
215
141
 
216
- const isPasswordCorrect = await bcrypt.compare(currentPassword, userRecord.password_hash)
142
+ const hashProvider = context.get('hashProvider')
143
+ const isPasswordCorrect = await hashProvider.verify(currentPassword, userRecord.passwordHash)
217
144
  if (!isPasswordCorrect) {
218
- return context.json({
219
- type: 'invalid-credentials',
220
- title: 'Unauthorized',
221
- status: 401,
222
- detail: 'Current password is incorrect'
223
- }, 401)
145
+ return context.json({ type: 'invalid-credentials', title: 'Unauthorized', status: 401, detail: 'Current password is incorrect' }, 401)
224
146
  }
225
147
 
226
- const hashedNewPassword = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS)
227
- await context.env.DB.prepare(
228
- 'UPDATE users SET password_hash = ? WHERE id = ?'
229
- ).bind(hashedNewPassword, userId).run()
230
-
148
+ const hashedNewPassword = await hashProvider.hash(newPassword)
149
+ await context.get('userRepository').updatePasswordHash(userId, hashedNewPassword)
231
150
  return context.json({ success: true })
232
151
  })
233
152
 
@@ -237,7 +156,7 @@ settingsApp.put('/password', async (context) => {
237
156
  */
238
157
  settingsApp.put('/avatar', async (context) => {
239
158
  const { sub: userId } = context.get('jwtPayload')
240
-
159
+
241
160
  let payload: Record<string, unknown>
242
161
  try {
243
162
  payload = await context.req.json()
@@ -246,11 +165,7 @@ settingsApp.put('/avatar', async (context) => {
246
165
  }
247
166
 
248
167
  const avatarUrl = typeof payload.avatarUrl === 'string' ? payload.avatarUrl.trim() : null
249
-
250
- await context.env.DB.prepare(
251
- 'UPDATE users SET avatar_url = ? WHERE id = ?'
252
- ).bind(avatarUrl, userId).run()
253
-
168
+ await context.get('userRepository').updateAvatarUrl(userId, avatarUrl)
254
169
  return context.json({ success: true })
255
170
  })
256
171
 
@@ -260,15 +175,9 @@ settingsApp.put('/avatar', async (context) => {
260
175
  */
261
176
  settingsApp.get('/sessions', async (context) => {
262
177
  const { sub: userId } = context.get('jwtPayload')
263
- const currentTimestamp = Math.floor(Date.now() / 1000)
264
-
265
- const sessionsResult = await context.env.DB.prepare(
266
- `SELECT id, created_at, expires_at FROM refresh_tokens
267
- WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
268
- ORDER BY created_at DESC LIMIT 20`
269
- ).bind(userId, currentTimestamp).all<SessionRow>()
270
-
271
- return context.json(sessionsResult.results ?? [])
178
+ const nowTimestamp = Math.floor(Date.now() / 1000)
179
+ const sessions = await context.get('sessionRepository').listActiveForUser(userId, nowTimestamp, SESSION_LIST_LIMIT)
180
+ return context.json(sessions)
272
181
  })
273
182
 
274
183
  /**
@@ -278,18 +187,13 @@ settingsApp.get('/sessions', async (context) => {
278
187
  settingsApp.delete('/sessions/:id', async (context) => {
279
188
  const { sub: userId } = context.get('jwtPayload')
280
189
  const sessionId = context.req.param('id')
281
- const currentTimestamp = Math.floor(Date.now() / 1000)
282
-
283
- const dbUpdateResult = await context.env.DB.prepare(
284
- `UPDATE refresh_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL`
285
- ).bind(currentTimestamp, sessionId, userId).run()
286
-
287
- const affectedRowsCount = (dbUpdateResult as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
288
-
289
- if (affectedRowsCount === 0) {
190
+ const nowTimestamp = Math.floor(Date.now() / 1000)
191
+
192
+ const wasRevoked = await context.get('sessionRepository').revokeById(sessionId, userId, nowTimestamp)
193
+ if (!wasRevoked) {
290
194
  return context.json({ error: 'Session not found or already revoked' }, 404)
291
195
  }
292
-
196
+
293
197
  return context.json({ success: true })
294
198
  })
295
199
 
@@ -299,14 +203,23 @@ settingsApp.delete('/sessions/:id', async (context) => {
299
203
  */
300
204
  settingsApp.get('/activity', async (context) => {
301
205
  const { sub: userId } = context.get('jwtPayload')
302
-
303
- const activityLogsResult = await context.env.DB.prepare(
304
- `SELECT id, action, entity_type, entity_slug, details, created_at
305
- FROM activity_logs WHERE user_id = ?
306
- ORDER BY created_at DESC LIMIT 30`
307
- ).bind(userId).all<ActivityRow>()
308
-
309
- return context.json(activityLogsResult.results ?? [])
206
+
207
+ const entries = await context.get('activityLogRepository').list({
208
+ userId,
209
+ limit: ACTIVITY_LOG_LIMIT,
210
+ })
211
+
212
+ // Preserve legacy snake_case shape consumed by the dashboard activity tab.
213
+ const responseEntries = entries.map((entry) => ({
214
+ id: entry.id,
215
+ action: entry.action,
216
+ entity_type: entry.entityType,
217
+ entity_slug: entry.entitySlug,
218
+ details: entry.details ? JSON.stringify(entry.details) : null,
219
+ created_at: entry.createdAt,
220
+ }))
221
+
222
+ return context.json(responseEntries)
310
223
  })
311
224
 
312
225
  /**
@@ -316,31 +229,12 @@ settingsApp.get('/activity', async (context) => {
316
229
  settingsApp.get('/storage', async (context) => {
317
230
  const mediaRepo = context.get('mediaRepository')
318
231
  const statsRepo = context.get('systemStatsRepository')
319
-
232
+
320
233
  const totalStorageUsedBytes = await statsRepo.getStorageUsage()
321
234
  const totalFileCount = await mediaRepo.count()
322
235
 
323
- // Collect all media keys referenced in file-type columns across all seeds
324
- const referencedMediaKeys = new Set<string>()
325
- const registeredSeeds = Object.values(context.get('seedRegistry'))
326
-
327
- for (const seed of registeredSeeds) {
328
- const mediaFields = seed.branches.filter(branch => branch.type === 'file')
329
- if (mediaFields.length === 0) continue
330
-
331
- const mediaColumns = mediaFields.map(field => field.alias).join(', ')
332
- const contentData = await context.env.DB.prepare(
333
- `SELECT ${mediaColumns} FROM content_${seed.slug}`
334
- ).all<Record<string, string | null>>()
335
-
336
- for (const contentRow of contentData.results ?? []) {
337
- const rowContentString = Object.values(contentRow).filter(Boolean).join(' ')
338
- // Simple regex to find media keys in stored URLs or strings
339
- for (const keyMatch of rowContentString.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
340
- referencedMediaKeys.add(decodeURIComponent(keyMatch[1]))
341
- }
342
- }
343
- }
236
+ const registeredSeeds = context.get('seedRegistry').all()
237
+ const referencedMediaKeys = await context.get('contentScanRepository').getReferencedMediaKeys(registeredSeeds)
344
238
 
345
239
  const { items: allMediaRows } = await mediaRepo.list({ limit: 50, offset: 0 })
346
240
  const orphanedMediaFiles = allMediaRows.filter(mediaFile => !referencedMediaKeys.has(mediaFile.key))
@@ -358,18 +252,15 @@ settingsApp.get('/storage', async (context) => {
358
252
  */
359
253
  settingsApp.get('/notifications', async (context) => {
360
254
  const { sub: userId } = context.get('jwtPayload')
361
-
362
- const userRecord = await context.env.DB.prepare(
363
- 'SELECT notification_prefs FROM users WHERE id = ? LIMIT 1'
364
- ).bind(userId).first<{ notification_prefs: string }>()
365
-
255
+
256
+ const userRecord = await context.get('userRepository').findById(userId)
366
257
  if (!userRecord) {
367
258
  return context.json({ error: 'User not found' }, 404)
368
259
  }
369
260
 
370
261
  let userPreferences: Record<string, boolean>
371
262
  try {
372
- userPreferences = JSON.parse(userRecord.notification_prefs || '{}')
263
+ userPreferences = JSON.parse(userRecord.notificationPreferences || '{}')
373
264
  } catch {
374
265
  userPreferences = {}
375
266
  }
@@ -388,7 +279,7 @@ settingsApp.get('/notifications', async (context) => {
388
279
  */
389
280
  settingsApp.put('/notifications', async (context) => {
390
281
  const { sub: userId } = context.get('jwtPayload')
391
-
282
+
392
283
  let payload: Record<string, unknown>
393
284
  try {
394
285
  payload = await context.req.json()
@@ -403,12 +294,8 @@ settingsApp.put('/notifications', async (context) => {
403
294
  mediaUpload: payload.mediaUpload === true,
404
295
  }
405
296
 
406
- await context.env.DB.prepare(
407
- 'UPDATE users SET notification_prefs = ? WHERE id = ?'
408
- ).bind(JSON.stringify(newPreferences), userId).run()
409
-
297
+ await context.get('userRepository').updateNotificationPreferences(userId, JSON.stringify(newPreferences))
410
298
  return context.json({ success: true })
411
299
  })
412
300
 
413
301
  export { settingsApp }
414
-
@@ -1,6 +1,5 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
- import bcrypt from 'bcryptjs'
4
3
  import type { Env, Variables } from '../../types'
5
4
  import { publicProblem } from '../../public/problem-details'
6
5
 
@@ -11,12 +10,8 @@ const setupApp = new Hono<{ Bindings: Env; Variables: Variables }>()
11
10
  * Checks if the application needs an initial setup (i.e., if no users exist).
12
11
  */
13
12
  setupApp.get('/auth/setup', async (context) => {
14
- const userCountResult = await context.env.DB
15
- .prepare('SELECT COUNT(*) as count FROM users')
16
- .first<{ count: number }>()
17
-
18
- const needsInitialSetup = (userCountResult?.count ?? 0) === 0
19
- return context.json({ needsSetup: needsInitialSetup })
13
+ const userCount = await context.get('userRepository').countAll()
14
+ return context.json({ needsSetup: userCount === 0 })
20
15
  })
21
16
 
22
17
  /**
@@ -24,11 +19,9 @@ setupApp.get('/auth/setup', async (context) => {
24
19
  * Creates the first administrator account. This endpoint is disabled once at least one user exists.
25
20
  */
26
21
  setupApp.post('/auth/setup', async (context) => {
27
- const userCountResult = await context.env.DB
28
- .prepare('SELECT COUNT(*) as count FROM users')
29
- .first<{ count: number }>()
22
+ const userCount = await context.get('userRepository').countAll()
30
23
 
31
- if ((userCountResult?.count ?? 0) > 0) {
24
+ if (userCount > 0) {
32
25
  return publicProblem(context, {
33
26
  type: 'setup-already-done',
34
27
  title: 'Setup already completed',
@@ -78,15 +71,17 @@ setupApp.post('/auth/setup', async (context) => {
78
71
  })
79
72
  }
80
73
 
81
- const hashedPassword = await bcrypt.hash(password, 12)
82
- const newUserId = crypto.randomUUID()
74
+ const passwordHash = await context.get('hashProvider').hash(password)
83
75
  const normalizedEmail = email.trim().toLowerCase()
84
76
  const normalizedName = typeof name === 'string' ? name.trim() : null
85
77
 
86
- await context.env.DB
87
- .prepare('INSERT INTO users (id, email, password_hash, role, name) VALUES (?, ?, ?, ?, ?)')
88
- .bind(newUserId, normalizedEmail, hashedPassword, 'admin', normalizedName)
89
- .run()
78
+ await context.get('userRepository').create({
79
+ id: context.get('idGenerator').uuid(),
80
+ email: normalizedEmail,
81
+ passwordHash,
82
+ role: 'admin',
83
+ name: normalizedName,
84
+ })
90
85
 
91
86
  return context.json({ success: true }, 201)
92
87
  })