@beechcms/api 0.5.0 → 0.6.0-preview.2

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 (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,114 +1,118 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type {
3
- INotificationRepository,
4
- NotificationRecord,
5
- NotificationStats,
6
- NotificationType,
7
- IClock,
8
- IIdGenerator,
9
- } from '@beechcms/core'
10
-
11
- interface NotificationRow {
12
- id: string
13
- title: string
14
- message: string
15
- type: string
16
- is_read: number
17
- created_at: number
18
- }
19
-
20
- interface NotificationStatsRow {
21
- total_count: number | null
22
- latest_created_at: number | null
23
- read_count: number | null
24
- }
25
-
26
- /**
27
- * D1-backed implementation of {@link INotificationRepository}.
28
- */
29
- export class D1NotificationRepository implements INotificationRepository {
30
- constructor(
31
- private readonly database: D1Database,
32
- private readonly clock: IClock,
33
- private readonly idGenerator: IIdGenerator,
34
- ) {}
35
-
36
- async list(limit: number): Promise<NotificationRecord[]> {
37
- const queryResult = await this.database
38
- .prepare(
39
- `SELECT id, title, message, type, is_read, created_at
40
- FROM notifications
41
- ORDER BY created_at DESC
42
- LIMIT ?`
43
- )
44
- .bind(limit)
45
- .all<NotificationRow>()
46
-
47
- return (queryResult.results ?? []).map(mapRowToRecord)
48
- }
49
-
50
- async stats(): Promise<NotificationStats> {
51
- const statsRow = await this.database
52
- .prepare(
53
- `SELECT COUNT(*) as total_count,
54
- MAX(created_at) as latest_created_at,
55
- SUM(is_read) as read_count
56
- FROM notifications`
57
- )
58
- .first<NotificationStatsRow>()
59
-
60
- return {
61
- totalCount: statsRow?.total_count ?? 0,
62
- latestCreatedAt: statsRow?.latest_created_at ?? 0,
63
- readCount: statsRow?.read_count ?? 0,
64
- }
65
- }
66
-
67
- async create(record: Omit<NotificationRecord, 'id' | 'createdAt' | 'isRead'>): Promise<string> {
68
- const generatedId = this.idGenerator.uuid()
69
- await this.database
70
- .prepare(
71
- `INSERT INTO notifications (id, title, message, type)
72
- VALUES (?, ?, ?, ?)`
73
- )
74
- .bind(generatedId, record.title, record.message, record.type)
75
- .run()
76
- return generatedId
77
- }
78
-
79
- async markRead(notificationId: string): Promise<void> {
80
- await this.database
81
- .prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
82
- .bind(notificationId)
83
- .run()
84
- }
85
-
86
- async markUnread(notificationId: string): Promise<void> {
87
- await this.database
88
- .prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
89
- .bind(notificationId)
90
- .run()
91
- }
92
-
93
- async markAllRead(): Promise<void> {
94
- await this.database.prepare('UPDATE notifications SET is_read = 1').run()
95
- }
96
-
97
- async delete(notificationId: string): Promise<void> {
98
- await this.database
99
- .prepare('DELETE FROM notifications WHERE id = ?')
100
- .bind(notificationId)
101
- .run()
102
- }
103
- }
104
-
105
- function mapRowToRecord(row: NotificationRow): NotificationRecord {
106
- return {
107
- id: row.id,
108
- title: row.title,
109
- message: row.message,
110
- type: row.type as NotificationType,
111
- isRead: row.is_read === 1,
112
- createdAt: row.created_at,
113
- }
114
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /// <reference types="@cloudflare/workers-types" />
6
+ import type {
7
+ INotificationRepository,
8
+ NotificationRecord,
9
+ NotificationStats,
10
+ NotificationType,
11
+ IClock,
12
+ IIdGenerator,
13
+ } from '@beechcms/core'
14
+
15
+ interface NotificationRow {
16
+ id: string
17
+ title: string
18
+ message: string
19
+ type: string
20
+ is_read: number
21
+ created_at: number
22
+ }
23
+
24
+ interface NotificationStatsRow {
25
+ total_count: number | null
26
+ latest_created_at: number | null
27
+ read_count: number | null
28
+ }
29
+
30
+ /**
31
+ * D1-backed implementation of {@link INotificationRepository}.
32
+ */
33
+ export class D1NotificationRepository implements INotificationRepository {
34
+ constructor(
35
+ private readonly database: D1Database,
36
+ private readonly clock: IClock,
37
+ private readonly idGenerator: IIdGenerator,
38
+ ) {}
39
+
40
+ async list(limit: number): Promise<NotificationRecord[]> {
41
+ const queryResult = await this.database
42
+ .prepare(
43
+ `SELECT id, title, message, type, is_read, created_at
44
+ FROM notifications
45
+ ORDER BY created_at DESC
46
+ LIMIT ?`
47
+ )
48
+ .bind(limit)
49
+ .all<NotificationRow>()
50
+
51
+ return (queryResult.results ?? []).map(mapRowToRecord)
52
+ }
53
+
54
+ async stats(): Promise<NotificationStats> {
55
+ const statsRow = await this.database
56
+ .prepare(
57
+ `SELECT COUNT(*) as total_count,
58
+ MAX(created_at) as latest_created_at,
59
+ SUM(is_read) as read_count
60
+ FROM notifications`
61
+ )
62
+ .first<NotificationStatsRow>()
63
+
64
+ return {
65
+ totalCount: statsRow?.total_count ?? 0,
66
+ latestCreatedAt: statsRow?.latest_created_at ?? 0,
67
+ readCount: statsRow?.read_count ?? 0,
68
+ }
69
+ }
70
+
71
+ async create(record: Omit<NotificationRecord, 'id' | 'createdAt' | 'isRead'>): Promise<string> {
72
+ const generatedId = this.idGenerator.uuid()
73
+ await this.database
74
+ .prepare(
75
+ `INSERT INTO notifications (id, title, message, type)
76
+ VALUES (?, ?, ?, ?)`
77
+ )
78
+ .bind(generatedId, record.title, record.message, record.type)
79
+ .run()
80
+ return generatedId
81
+ }
82
+
83
+ async markRead(notificationId: string): Promise<void> {
84
+ await this.database
85
+ .prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
86
+ .bind(notificationId)
87
+ .run()
88
+ }
89
+
90
+ async markUnread(notificationId: string): Promise<void> {
91
+ await this.database
92
+ .prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
93
+ .bind(notificationId)
94
+ .run()
95
+ }
96
+
97
+ async markAllRead(): Promise<void> {
98
+ await this.database.prepare('UPDATE notifications SET is_read = 1').run()
99
+ }
100
+
101
+ async delete(notificationId: string): Promise<void> {
102
+ await this.database
103
+ .prepare('DELETE FROM notifications WHERE id = ?')
104
+ .bind(notificationId)
105
+ .run()
106
+ }
107
+ }
108
+
109
+ function mapRowToRecord(row: NotificationRow): NotificationRecord {
110
+ return {
111
+ id: row.id,
112
+ title: row.title,
113
+ message: row.message,
114
+ type: row.type as NotificationType,
115
+ isRead: row.is_read === 1,
116
+ createdAt: row.created_at,
117
+ }
118
+ }
@@ -1,77 +1,81 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { D1PasswordResetTokenRepository } from './d1-password-reset-token.repository'
3
-
4
- const NOW = Math.floor(Date.now() / 1000)
5
- const fixedIdGen = { uuid: () => 'prt-1' }
6
-
7
- function makeMockDb(opts: { firstResult?: unknown; runChanges?: number } = {}) {
8
- const { firstResult = null, runChanges = 1 } = opts
9
- const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
10
- const firstMock = vi.fn().mockResolvedValue(firstResult)
11
- const bindMock = vi.fn(() => ({ first: firstMock, run: runMock }))
12
- const prepareMock = vi.fn(() => ({ bind: bindMock }))
13
- return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
14
- }
15
-
16
- describe('D1PasswordResetTokenRepository', () => {
17
- describe('create', () => {
18
- it('calls INSERT INTO password_reset_tokens', async () => {
19
- const { db, prepareMock } = makeMockDb()
20
- await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
21
- userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
22
- })
23
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO password_reset_tokens'))
24
- })
25
-
26
- it('binds id, userId, tokenHash, and expiresAt in the correct order', async () => {
27
- const { db, bindMock } = makeMockDb()
28
- await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
29
- userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
30
- })
31
- expect(bindMock).toHaveBeenCalledWith('prt-1', 'u1', 'h', NOW + 1800)
32
- })
33
- })
34
-
35
- describe('findValidByHashWithEmail', () => {
36
- it('returns a ValidatedResetToken with the userId and email from the JOIN', async () => {
37
- const row = { id: 'prt-1', user_id: 'u1', email: 'user@test.com' }
38
- const { db } = makeMockDb({ firstResult: row })
39
- const result = await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('h', NOW)
40
- expect(result).toEqual({ id: 'prt-1', userId: 'u1', email: 'user@test.com' })
41
- })
42
-
43
- it('returns null when the token is not found, expired, or already used', async () => {
44
- const { db } = makeMockDb({ firstResult: null })
45
- expect(await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('bad-hash', NOW)).toBeNull()
46
- })
47
-
48
- it('passes tokenHash and nowTimestamp as bound values', async () => {
49
- const { db, bindMock } = makeMockDb()
50
- await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('my-hash', NOW)
51
- expect(bindMock).toHaveBeenCalledWith('my-hash', NOW)
52
- })
53
- })
54
-
55
- describe('markUsed', () => {
56
- it('calls UPDATE password_reset_tokens SET used_at', async () => {
57
- const { db, prepareMock } = makeMockDb()
58
- await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
59
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('used_at'))
60
- })
61
-
62
- it('binds nowTimestamp and tokenId in the correct order', async () => {
63
- const { db, bindMock } = makeMockDb()
64
- await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
65
- expect(bindMock).toHaveBeenCalledWith(NOW, 'prt-1')
66
- })
67
- })
68
-
69
- describe('invalidatePending', () => {
70
- it('calls UPDATE password_reset_tokens and includes the userId in the bound values', async () => {
71
- const { db, prepareMock, bindMock } = makeMockDb()
72
- await new D1PasswordResetTokenRepository(db, fixedIdGen).invalidatePending('u1', NOW)
73
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE password_reset_tokens'))
74
- expect((bindMock.mock.calls[0] as unknown[]).includes('u1')).toBe(true)
75
- })
76
- })
77
- })
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { describe, it, expect, vi } from 'vitest'
6
+ import { D1PasswordResetTokenRepository } from './d1-password-reset-token.repository'
7
+
8
+ const NOW = Math.floor(Date.now() / 1000)
9
+ const fixedIdGen = { uuid: () => 'prt-1' }
10
+
11
+ function makeMockDb(opts: { firstResult?: unknown; runChanges?: number } = {}) {
12
+ const { firstResult = null, runChanges = 1 } = opts
13
+ const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
14
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
15
+ const bindMock = vi.fn(() => ({ first: firstMock, run: runMock }))
16
+ const prepareMock = vi.fn(() => ({ bind: bindMock }))
17
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
18
+ }
19
+
20
+ describe('D1PasswordResetTokenRepository', () => {
21
+ describe('create', () => {
22
+ it('calls INSERT INTO password_reset_tokens', async () => {
23
+ const { db, prepareMock } = makeMockDb()
24
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
25
+ userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
26
+ })
27
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO password_reset_tokens'))
28
+ })
29
+
30
+ it('binds id, userId, tokenHash, and expiresAt in the correct order', async () => {
31
+ const { db, bindMock } = makeMockDb()
32
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
33
+ userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
34
+ })
35
+ expect(bindMock).toHaveBeenCalledWith('prt-1', 'u1', 'h', NOW + 1800)
36
+ })
37
+ })
38
+
39
+ describe('findValidByHashWithEmail', () => {
40
+ it('returns a ValidatedResetToken with the userId and email from the JOIN', async () => {
41
+ const row = { id: 'prt-1', user_id: 'u1', email: 'user@test.com' }
42
+ const { db } = makeMockDb({ firstResult: row })
43
+ const result = await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('h', NOW)
44
+ expect(result).toEqual({ id: 'prt-1', userId: 'u1', email: 'user@test.com' })
45
+ })
46
+
47
+ it('returns null when the token is not found, expired, or already used', async () => {
48
+ const { db } = makeMockDb({ firstResult: null })
49
+ expect(await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('bad-hash', NOW)).toBeNull()
50
+ })
51
+
52
+ it('passes tokenHash and nowTimestamp as bound values', async () => {
53
+ const { db, bindMock } = makeMockDb()
54
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('my-hash', NOW)
55
+ expect(bindMock).toHaveBeenCalledWith('my-hash', NOW)
56
+ })
57
+ })
58
+
59
+ describe('markUsed', () => {
60
+ it('calls UPDATE password_reset_tokens SET used_at', async () => {
61
+ const { db, prepareMock } = makeMockDb()
62
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
63
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('used_at'))
64
+ })
65
+
66
+ it('binds nowTimestamp and tokenId in the correct order', async () => {
67
+ const { db, bindMock } = makeMockDb()
68
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
69
+ expect(bindMock).toHaveBeenCalledWith(NOW, 'prt-1')
70
+ })
71
+ })
72
+
73
+ describe('invalidatePending', () => {
74
+ it('calls UPDATE password_reset_tokens and includes the userId in the bound values', async () => {
75
+ const { db, prepareMock, bindMock } = makeMockDb()
76
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).invalidatePending('u1', NOW)
77
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE password_reset_tokens'))
78
+ expect((bindMock.mock.calls[0] as unknown[]).includes('u1')).toBe(true)
79
+ })
80
+ })
81
+ })
@@ -1,52 +1,56 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type { IPasswordResetTokenRepository, NewPasswordResetToken, ValidatedResetToken, IIdGenerator } from '@beechcms/core'
3
-
4
- type ValidatedResetTokenRow = {
5
- id: string
6
- user_id: string
7
- email: string
8
- }
9
-
10
- export class D1PasswordResetTokenRepository implements IPasswordResetTokenRepository {
11
- constructor(
12
- private readonly db: D1Database,
13
- private readonly idGenerator: IIdGenerator,
14
- ) {}
15
-
16
- async invalidatePending(userId: string, nowTimestamp: number): Promise<void> {
17
- await this.db
18
- .prepare('UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL')
19
- .bind(nowTimestamp, userId)
20
- .run()
21
- }
22
-
23
- async create(record: NewPasswordResetToken): Promise<void> {
24
- const generatedId = this.idGenerator.uuid()
25
- await this.db
26
- .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
27
- .bind(generatedId, record.userId, record.tokenHash, record.expiresAt)
28
- .run()
29
- }
30
-
31
- async findValidByHashWithEmail(tokenHash: string, nowTimestamp: number): Promise<ValidatedResetToken | null> {
32
- const row = await this.db
33
- .prepare(
34
- `SELECT prt.id, prt.user_id, u.email
35
- FROM password_reset_tokens prt
36
- JOIN users u ON u.id = prt.user_id
37
- WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`
38
- )
39
- .bind(tokenHash, nowTimestamp)
40
- .first<ValidatedResetTokenRow>()
41
-
42
- if (!row) return null
43
- return { id: row.id, userId: row.user_id, email: row.email }
44
- }
45
-
46
- async markUsed(tokenId: string, nowTimestamp: number): Promise<void> {
47
- await this.db
48
- .prepare('UPDATE password_reset_tokens SET used_at = ? WHERE id = ?')
49
- .bind(nowTimestamp, tokenId)
50
- .run()
51
- }
52
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /// <reference types="@cloudflare/workers-types" />
6
+ import type { IPasswordResetTokenRepository, NewPasswordResetToken, ValidatedResetToken, IIdGenerator } from '@beechcms/core'
7
+
8
+ type ValidatedResetTokenRow = {
9
+ id: string
10
+ user_id: string
11
+ email: string
12
+ }
13
+
14
+ export class D1PasswordResetTokenRepository implements IPasswordResetTokenRepository {
15
+ constructor(
16
+ private readonly db: D1Database,
17
+ private readonly idGenerator: IIdGenerator,
18
+ ) {}
19
+
20
+ async invalidatePending(userId: string, nowTimestamp: number): Promise<void> {
21
+ await this.db
22
+ .prepare('UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL')
23
+ .bind(nowTimestamp, userId)
24
+ .run()
25
+ }
26
+
27
+ async create(record: NewPasswordResetToken): Promise<void> {
28
+ const generatedId = this.idGenerator.uuid()
29
+ await this.db
30
+ .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
31
+ .bind(generatedId, record.userId, record.tokenHash, record.expiresAt)
32
+ .run()
33
+ }
34
+
35
+ async findValidByHashWithEmail(tokenHash: string, nowTimestamp: number): Promise<ValidatedResetToken | null> {
36
+ const row = await this.db
37
+ .prepare(
38
+ `SELECT prt.id, prt.user_id, u.email
39
+ FROM password_reset_tokens prt
40
+ JOIN users u ON u.id = prt.user_id
41
+ WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`
42
+ )
43
+ .bind(tokenHash, nowTimestamp)
44
+ .first<ValidatedResetTokenRow>()
45
+
46
+ if (!row) return null
47
+ return { id: row.id, userId: row.user_id, email: row.email }
48
+ }
49
+
50
+ async markUsed(tokenId: string, nowTimestamp: number): Promise<void> {
51
+ await this.db
52
+ .prepare('UPDATE password_reset_tokens SET used_at = ? WHERE id = ?')
53
+ .bind(nowTimestamp, tokenId)
54
+ .run()
55
+ }
56
+ }