@beechcms/api 0.4.0 → 0.4.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 (138) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/auth/bcrypt-hash-provider.ts +20 -0
  8. package/src/auth/constants.ts +3 -3
  9. package/src/auth/generate-refresh-token.test.ts +19 -0
  10. package/src/auth/hash-provider.test.ts +46 -0
  11. package/src/auth/in-memory-hash-provider.ts +13 -0
  12. package/src/auth/jose-token-service.ts +55 -0
  13. package/src/auth/login.test.ts +92 -0
  14. package/src/auth/login.ts +15 -32
  15. package/src/auth/refresh.ts +0 -122
  16. package/src/auth/static-token-service.ts +18 -0
  17. package/src/auth/token-service.test.ts +82 -0
  18. package/src/factory.ts +80 -80
  19. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  20. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  21. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  22. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  23. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  24. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  25. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  26. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  27. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  28. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  29. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  30. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  31. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  32. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  33. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  34. package/src/features/automations/action-executors/index.ts +33 -0
  35. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  36. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  37. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  38. package/src/features/automations/automation-runner.ts +81 -0
  39. package/src/features/automations/automation-runner.utils.ts +43 -0
  40. package/src/features/automations/automations.handler.ts +193 -0
  41. package/src/features/automations/automations.schema.ts +160 -0
  42. package/src/features/automations/context-resolver.ts +148 -0
  43. package/src/features/automations/cron-runner.ts +136 -0
  44. package/src/features/automations/cron-runner.utils.ts +40 -0
  45. package/src/features/automations/filter-translation.ts +42 -0
  46. package/src/features/automations/index.ts +12 -0
  47. package/src/features/automations/template-grammar.ts +241 -0
  48. package/src/features/automations/var-access-resolver.ts +136 -0
  49. package/src/features/automations/when-evaluator.ts +83 -0
  50. package/src/features/automations/when-pushdown.ts +53 -0
  51. package/src/features/content/handlers/create.ts +22 -10
  52. package/src/features/content/handlers/delete.ts +21 -10
  53. package/src/features/content/handlers/update.ts +21 -9
  54. package/src/features/draft/draft.handler.ts +51 -153
  55. package/src/features/draft/draft.middleware.ts +62 -0
  56. package/src/features/email/email.service.ts +13 -0
  57. package/src/features/email/email.types.ts +10 -0
  58. package/src/features/email/index.ts +2 -1
  59. package/src/features/email/templates/automation-mail.ts +15 -0
  60. package/src/features/notifications/notifications.handler.ts +25 -54
  61. package/src/features/password-reset/request.ts +17 -41
  62. package/src/features/password-reset/reset.ts +18 -54
  63. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  64. package/src/features/schema/schema.handler.ts +1 -1
  65. package/src/features/settings/settings.handler.ts +64 -176
  66. package/src/features/setup/index.ts +12 -17
  67. package/src/features/stats/stats.handler.ts +110 -138
  68. package/src/index.ts +40 -8
  69. package/src/middleware/auth-providers.middleware.ts +32 -0
  70. package/src/middleware/observability.middleware.ts +52 -0
  71. package/src/middleware/rate-limit.middleware.ts +41 -0
  72. package/src/middleware/repository.middleware.ts +72 -5
  73. package/src/middleware.ts +15 -35
  74. package/src/public/cache-utils.ts +34 -0
  75. package/src/public/entry-projection.ts +42 -0
  76. package/src/public/idempotency.ts +19 -0
  77. package/src/public/problem-details.ts +5 -0
  78. package/src/public/public-add.ts +110 -166
  79. package/src/public/public-edit.ts +4 -3
  80. package/src/public/public-read.ts +59 -216
  81. package/src/public/public-routes.ts +2 -2
  82. package/src/public/query-builder.test.ts +220 -0
  83. package/src/public/rate-limit-middleware.ts +7 -19
  84. package/src/public/read-list.ts +50 -0
  85. package/src/public/read-single.ts +44 -0
  86. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  87. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  88. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  89. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  90. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  91. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  92. package/src/search-utils.test.ts +207 -0
  93. package/src/search-utils.ts +18 -1
  94. package/src/search.ts +24 -35
  95. package/src/shared/apply-policies.test.ts +77 -0
  96. package/src/shared/automations.repository.d1.ts +146 -0
  97. package/src/shared/background-notification-service.test.ts +58 -0
  98. package/src/shared/background-notification-service.ts +48 -0
  99. package/src/shared/content-utils.test.ts +161 -0
  100. package/src/shared/content.repository.d1.test.ts +312 -0
  101. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  102. package/src/shared/d1-activity-log.repository.ts +101 -0
  103. package/src/shared/d1-activity-logger.test.ts +82 -0
  104. package/src/shared/d1-activity-logger.ts +63 -0
  105. package/src/shared/d1-analytics.repository.test.ts +74 -0
  106. package/src/shared/d1-analytics.repository.ts +81 -0
  107. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  108. package/src/shared/d1-content-scan.repository.ts +29 -0
  109. package/src/shared/d1-notification.repository.test.ts +124 -0
  110. package/src/shared/d1-notification.repository.ts +114 -0
  111. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  112. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  113. package/src/shared/d1-search.repository.test.ts +83 -0
  114. package/src/shared/d1-search.repository.ts +84 -0
  115. package/src/shared/d1-session.repository.test.ts +121 -0
  116. package/src/shared/d1-session.repository.ts +98 -0
  117. package/src/shared/d1-user.repository.test.ts +147 -0
  118. package/src/shared/d1-user.repository.ts +109 -0
  119. package/src/shared/d1-widget.repository.test.ts +217 -0
  120. package/src/shared/d1-widget.repository.ts +337 -0
  121. package/src/shared/execution-context-scheduler.ts +9 -0
  122. package/src/shared/fixed-clock.ts +21 -0
  123. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  124. package/src/shared/in-memory-activity-logger.ts +15 -0
  125. package/src/shared/in-memory-notification-service.ts +15 -0
  126. package/src/shared/media.repository.d1.test.ts +103 -0
  127. package/src/shared/media.repository.d1.ts +1 -1
  128. package/src/shared/request-utils.ts +22 -0
  129. package/src/shared/sequential-id-generator.ts +22 -0
  130. package/src/shared/storage-utils.ts +3 -3
  131. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  132. package/src/types.ts +24 -3
  133. package/src/upload.ts +17 -9
  134. package/src/widget.ts +112 -253
  135. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
  136. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  137. package/src/shared/activity-logger.ts +0 -79
  138. package/src/shared/notification-service.ts +0 -56
@@ -0,0 +1,337 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type {
3
+ Seed,
4
+ IWidgetRepository,
5
+ AggregateFormula,
6
+ TimeWindow,
7
+ LeaderboardEntry,
8
+ LeaderboardOptions,
9
+ TimeseriesPoint,
10
+ WidgetListOptions,
11
+ WidgetListResult,
12
+ GrowthResult,
13
+ } from '@beechcms/core'
14
+
15
+ const SYSTEM_COLUMNS: ReadonlySet<string> = new Set([
16
+ 'id',
17
+ 'slug',
18
+ 'status',
19
+ 'created_at',
20
+ 'updated_at',
21
+ ])
22
+
23
+ const ALLOWED_FILTER_OPERATORS: ReadonlySet<string> = new Set([
24
+ 'eq',
25
+ '=',
26
+ 'neq',
27
+ '!=',
28
+ 'like',
29
+ 'gt',
30
+ '>',
31
+ 'lt',
32
+ '<',
33
+ ])
34
+
35
+ const UNSAFE_COLUMN_ERROR = 'UNSAFE_COLUMN'
36
+
37
+ /**
38
+ * D1-backed implementation of {@link IWidgetRepository}.
39
+ *
40
+ * All user-supplied values are bound via parameterised statements. Column
41
+ * aliases originating from query strings are validated against the seed
42
+ * before being composed into the SQL string, preventing column-name
43
+ * injection. SQL keywords (aggregate function names, ORDER direction) are
44
+ * selected via hardcoded branches.
45
+ */
46
+ export class D1WidgetRepository implements IWidgetRepository {
47
+ constructor(private readonly database: D1Database) {}
48
+
49
+ async aggregate(seed: Seed, formula: AggregateFormula, window: TimeWindow): Promise<number> {
50
+ const aggregateExpression = this.buildAggregateExpression(seed, formula)
51
+ const timeWindowFilter = this.buildTimeWindowFilter(window)
52
+ const tableName = `content_${seed.slug}`
53
+
54
+ const sql =
55
+ `SELECT ${aggregateExpression} as computed_value
56
+ FROM ${tableName}
57
+ WHERE ${timeWindowFilter}`
58
+
59
+ const row = await this.database.prepare(sql).first<{ computed_value: number | null }>()
60
+ return row?.computed_value ?? 0
61
+ }
62
+
63
+ async growth(
64
+ seed: Seed,
65
+ formula: AggregateFormula,
66
+ window: TimeWindow,
67
+ ): Promise<GrowthResult> {
68
+ const aggregateExpression = this.buildAggregateExpression(seed, formula)
69
+ const { currentFilter, previousFilter } = this.buildPreviousWindowFilter(window)
70
+ const tableName = `content_${seed.slug}`
71
+
72
+ const [currentRow, previousRow] = await Promise.all([
73
+ this.database
74
+ .prepare(`SELECT ${aggregateExpression} as computed_value FROM ${tableName} WHERE ${currentFilter}`)
75
+ .first<{ computed_value: number | null }>(),
76
+ this.database
77
+ .prepare(`SELECT ${aggregateExpression} as computed_value FROM ${tableName} WHERE ${previousFilter}`)
78
+ .first<{ computed_value: number | null }>(),
79
+ ])
80
+
81
+ return {
82
+ currentValue: currentRow?.computed_value ?? 0,
83
+ previousValue: previousRow?.computed_value ?? 0,
84
+ }
85
+ }
86
+
87
+ async leaderboard(seed: Seed, options: LeaderboardOptions): Promise<LeaderboardEntry[]> {
88
+ const scoreColumn = this.resolveColumnExpression(seed, options.scoreColumn)
89
+ const labelColumn = this.resolveColumnExpression(seed, seed.displayNameAlias)
90
+ const tableName = `content_${seed.slug}`
91
+ const orderClause = options.orderDirection === 'ASC'
92
+ ? `ORDER BY CAST(${scoreColumn} AS REAL) ASC`
93
+ : `ORDER BY CAST(${scoreColumn} AS REAL) DESC`
94
+
95
+ const sql =
96
+ `SELECT id, ${labelColumn} as label, ${scoreColumn} as score
97
+ FROM ${tableName}
98
+ WHERE ${scoreColumn} IS NOT NULL
99
+ ${orderClause}
100
+ LIMIT ?`
101
+
102
+ const rows = await this.database
103
+ .prepare(sql)
104
+ .bind(options.limit)
105
+ .all<{ id: string; label: string | null; score: number | string | null }>()
106
+
107
+ return (rows.results ?? []).map(row => ({
108
+ id: row.id,
109
+ label: row.label ?? row.id,
110
+ score: row.score ?? 0,
111
+ }))
112
+ }
113
+
114
+ async list(seed: Seed, options: WidgetListOptions): Promise<WidgetListResult> {
115
+ const tableName = `content_${seed.slug}`
116
+ const conditions: string[] = []
117
+ const bindings: unknown[] = []
118
+
119
+ const trimmedSearch = options.search?.trim() ?? ''
120
+ if (trimmedSearch.length > 0) {
121
+ const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias)
122
+ conditions.push(`${displayColumn} LIKE ?`)
123
+ bindings.push(`%${trimmedSearch}%`)
124
+ }
125
+
126
+ for (const filter of options.filters ?? []) {
127
+ this.appendFilterCondition(seed, filter, conditions, bindings)
128
+ }
129
+
130
+ const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
131
+ const orderClause = this.buildListOrderClause(seed, options)
132
+ const branchColumns = seed.branches.map(branch => branch.alias).join(', ')
133
+ const selectColumns = branchColumns.length > 0
134
+ ? `id, slug, status, created_at, updated_at, ${branchColumns}`
135
+ : `id, slug, status, created_at, updated_at`
136
+
137
+ const dataSql =
138
+ `SELECT ${selectColumns}
139
+ FROM ${tableName}
140
+ ${whereSql}
141
+ ${orderClause}
142
+ LIMIT ? OFFSET ?`
143
+
144
+ const countSql = `SELECT COUNT(*) as total FROM ${tableName} ${whereSql}`
145
+
146
+ const [countRow, listRows] = await Promise.all([
147
+ this.database.prepare(countSql).bind(...bindings).first<{ total: number }>(),
148
+ this.database
149
+ .prepare(dataSql)
150
+ .bind(...bindings, options.limit, options.offset)
151
+ .all<Record<string, unknown>>(),
152
+ ])
153
+
154
+ return {
155
+ entries: listRows.results ?? [],
156
+ totalCount: countRow?.total ?? 0,
157
+ }
158
+ }
159
+
160
+ async timeseries(
161
+ seed: Seed,
162
+ formula: AggregateFormula,
163
+ window: TimeWindow,
164
+ groupColumn: string,
165
+ ): Promise<TimeseriesPoint[]> {
166
+ const groupColumnExpression = this.resolveColumnExpression(seed, groupColumn)
167
+ const aggregateExpression = this.buildAggregateExpression(seed, formula)
168
+ const timeWindowFilter = this.buildTimeWindowFilter(window)
169
+ const tableName = `content_${seed.slug}`
170
+
171
+ const dateBucketExpression = groupColumnExpression === 'created_at'
172
+ ? `strftime('%Y-%m-%d', created_at, 'unixepoch')`
173
+ : `strftime('%Y-%m-%d', CAST(${groupColumnExpression} AS INTEGER), 'unixepoch')`
174
+
175
+ const sql =
176
+ `SELECT ${dateBucketExpression} as bucket_label,
177
+ ${aggregateExpression} as bucket_value
178
+ FROM ${tableName}
179
+ WHERE ${timeWindowFilter}
180
+ GROUP BY bucket_label
181
+ ORDER BY bucket_label ASC`
182
+
183
+ const rows = await this.database
184
+ .prepare(sql)
185
+ .all<{ bucket_label: string | null; bucket_value: number | null }>()
186
+
187
+ return (rows.results ?? []).map(row => ({
188
+ label: row.bucket_label ?? '',
189
+ value: row.bucket_value ?? 0,
190
+ }))
191
+ }
192
+
193
+ /**
194
+ * Throws UNSAFE_COLUMN if the alias is neither a system column nor a
195
+ * declared seed branch, preventing SQL injection via column names.
196
+ */
197
+ private resolveColumnExpression(seed: Seed, alias: string): string {
198
+ if (SYSTEM_COLUMNS.has(alias)) return alias
199
+ const branch = seed.branches.find(candidate => candidate.alias === alias)
200
+ if (!branch) throw new Error(UNSAFE_COLUMN_ERROR)
201
+ return branch.alias
202
+ }
203
+
204
+ private buildAggregateExpression(seed: Seed, formula: AggregateFormula): string {
205
+ switch (formula.op) {
206
+ case 'count':
207
+ return 'COUNT(*)'
208
+ case 'sum': {
209
+ const column = this.resolveColumnExpression(seed, formula.column)
210
+ return `SUM(CAST(${column} AS REAL))`
211
+ }
212
+ case 'avg': {
213
+ const column = this.resolveColumnExpression(seed, formula.column)
214
+ return `AVG(CAST(${column} AS REAL))`
215
+ }
216
+ case 'min': {
217
+ const column = this.resolveColumnExpression(seed, formula.column)
218
+ return `MIN(CAST(${column} AS REAL))`
219
+ }
220
+ case 'max': {
221
+ const column = this.resolveColumnExpression(seed, formula.column)
222
+ return `MAX(CAST(${column} AS REAL))`
223
+ }
224
+ case 'countWhere':
225
+ return this.buildCountWhereExpression(seed, formula.column, formula.value)
226
+ case 'percentageOf': {
227
+ const numerator = this.resolveColumnExpression(seed, formula.numeratorColumn)
228
+ const denominator = this.resolveColumnExpression(seed, formula.denominatorColumn)
229
+ return `CASE WHEN SUM(CAST(${denominator} AS REAL)) = 0 THEN 0 ELSE (SUM(CAST(${numerator} AS REAL)) * 100.0 / SUM(CAST(${denominator} AS REAL))) END`
230
+ }
231
+ }
232
+ }
233
+
234
+ private buildCountWhereExpression(seed: Seed, alias: string, value: unknown): string {
235
+ const column = this.resolveColumnExpression(seed, alias)
236
+ if (value === null) return `COUNT(CASE WHEN ${column} IS NULL THEN 1 END)`
237
+ if (typeof value === 'boolean') {
238
+ const numericValue = value ? 1 : 0
239
+ return `COUNT(CASE WHEN ${column} = ${numericValue} THEN 1 END)`
240
+ }
241
+ if (typeof value === 'number' && Number.isFinite(value)) {
242
+ return `COUNT(CASE WHEN CAST(${column} AS REAL) = ${value} THEN 1 END)`
243
+ }
244
+ const escaped = String(value).replace(/'/g, "''")
245
+ return `COUNT(CASE WHEN ${column} = '${escaped}' THEN 1 END)`
246
+ }
247
+
248
+ private buildTimeWindowFilter(window: TimeWindow): string {
249
+ switch (window) {
250
+ case 'week': return "created_at > unixepoch('now', '-7 days')"
251
+ case 'month': return "created_at > unixepoch('now', '-1 month')"
252
+ case 'year': return "created_at > unixepoch('now', '-1 year')"
253
+ case 'all': return '1=1'
254
+ }
255
+ }
256
+
257
+ private buildPreviousWindowFilter(
258
+ window: TimeWindow,
259
+ ): { currentFilter: string; previousFilter: string } {
260
+ switch (window) {
261
+ case 'week':
262
+ return {
263
+ currentFilter: "created_at > unixepoch('now', '-7 days')",
264
+ previousFilter: "created_at > unixepoch('now', '-14 days') AND created_at <= unixepoch('now', '-7 days')",
265
+ }
266
+ case 'month':
267
+ return {
268
+ currentFilter: "created_at > unixepoch('now', '-1 month')",
269
+ previousFilter: "created_at > unixepoch('now', '-2 months') AND created_at <= unixepoch('now', '-1 month')",
270
+ }
271
+ case 'year':
272
+ return {
273
+ currentFilter: "created_at > unixepoch('now', '-1 year')",
274
+ previousFilter: "created_at > unixepoch('now', '-2 years') AND created_at <= unixepoch('now', '-1 year')",
275
+ }
276
+ case 'all':
277
+ return { currentFilter: '1=1', previousFilter: '1=0' }
278
+ }
279
+ }
280
+
281
+ private appendFilterCondition(
282
+ seed: Seed,
283
+ filter: { column: string; op: string; value: unknown },
284
+ conditions: string[],
285
+ bindings: unknown[],
286
+ ): void {
287
+ if (!ALLOWED_FILTER_OPERATORS.has(filter.op)) return
288
+
289
+ let column: string
290
+ try {
291
+ column = this.resolveColumnExpression(seed, filter.column)
292
+ } catch {
293
+ return
294
+ }
295
+
296
+ switch (filter.op) {
297
+ case 'eq':
298
+ case '=':
299
+ conditions.push(`${column} = ?`)
300
+ bindings.push(filter.value)
301
+ return
302
+ case 'neq':
303
+ case '!=':
304
+ conditions.push(`${column} != ?`)
305
+ bindings.push(filter.value)
306
+ return
307
+ case 'like':
308
+ conditions.push(`${column} LIKE ?`)
309
+ bindings.push(filter.value)
310
+ return
311
+ case 'gt':
312
+ case '>':
313
+ conditions.push(`CAST(${column} AS REAL) > ?`)
314
+ bindings.push(filter.value)
315
+ return
316
+ case 'lt':
317
+ case '<':
318
+ conditions.push(`CAST(${column} AS REAL) < ?`)
319
+ bindings.push(filter.value)
320
+ return
321
+ }
322
+ }
323
+
324
+ private buildListOrderClause(seed: Seed, options: WidgetListOptions): string {
325
+ let orderColumn = 'created_at'
326
+ if (options.orderByColumn) {
327
+ try {
328
+ orderColumn = this.resolveColumnExpression(seed, options.orderByColumn)
329
+ } catch {
330
+ orderColumn = 'created_at'
331
+ }
332
+ }
333
+ return options.orderDirection === 'DESC'
334
+ ? `ORDER BY ${orderColumn} DESC`
335
+ : `ORDER BY ${orderColumn} ASC`
336
+ }
337
+ }
@@ -0,0 +1,9 @@
1
+ import type { IScheduler } from '@beechcms/core'
2
+
3
+ export class ExecutionContextScheduler implements IScheduler {
4
+ constructor(private readonly ctx: ExecutionContext) {}
5
+
6
+ waitUntil(promise: Promise<unknown>): void {
7
+ this.ctx.waitUntil(promise)
8
+ }
9
+ }
@@ -0,0 +1,21 @@
1
+ import type { IClock } from '@beechcms/core'
2
+
3
+ const MILLISECONDS_PER_SECOND = 1000
4
+
5
+ /**
6
+ * Test-only IClock implementation that returns a frozen timestamp on every
7
+ * call. Lets specs assert exact values for `createdAt`, `iat`, and
8
+ * day-bucket computations without resorting to vi.useFakeTimers or
9
+ * patching the global Date constructor.
10
+ */
11
+ export class FixedClock implements IClock {
12
+ constructor(private readonly fixedNowMs: number) {}
13
+
14
+ now(): number {
15
+ return this.fixedNowMs
16
+ }
17
+
18
+ nowSeconds(): number {
19
+ return Math.floor(this.fixedNowMs / MILLISECONDS_PER_SECOND)
20
+ }
21
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { D1IdempotencyRepository } from './idempotency.repository.d1'
3
+
4
+ function makeMockDb(opts: { firstResult?: unknown } = {}) {
5
+ const { firstResult = null } = opts
6
+ const runMock = vi.fn().mockResolvedValue({ success: true })
7
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
8
+ const bindMock = vi.fn(() => ({ run: runMock, first: firstMock }))
9
+ const prepareMock = vi.fn(() => ({ bind: bindMock }))
10
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
11
+ }
12
+
13
+ describe('D1IdempotencyRepository', () => {
14
+ describe('lookup', () => {
15
+ it('returns a mapped IdempotencyRecord when the key is found', async () => {
16
+ const row = {
17
+ idempotency_key: 'idem-1',
18
+ request_fingerprint: 'fp-abc',
19
+ response_status: 200,
20
+ response_body: '{"ok":true}',
21
+ expires_at: 9999,
22
+ }
23
+ const { db } = makeMockDb({ firstResult: row })
24
+ const result = await new D1IdempotencyRepository(db).lookup('idem-1')
25
+ expect(result).toEqual({
26
+ key: 'idem-1',
27
+ fingerprint: 'fp-abc',
28
+ responseStatus: 200,
29
+ responseBody: '{"ok":true}',
30
+ expiresAt: 9999,
31
+ })
32
+ })
33
+
34
+ it('returns null when the key is not found', async () => {
35
+ const { db } = makeMockDb({ firstResult: null })
36
+ expect(await new D1IdempotencyRepository(db).lookup('missing')).toBeNull()
37
+ })
38
+
39
+ it('queries the correct table', async () => {
40
+ const { db, prepareMock } = makeMockDb()
41
+ await new D1IdempotencyRepository(db).lookup('k')
42
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('public_idempotency_keys'))
43
+ })
44
+ })
45
+
46
+ describe('store', () => {
47
+ it('calls INSERT INTO public_idempotency_keys with upsert on conflict', async () => {
48
+ const { db, prepareMock } = makeMockDb()
49
+ await new D1IdempotencyRepository(db).store({
50
+ key: 'idem-1', fingerprint: 'fp', responseStatus: 201, responseBody: '{}', expiresAt: 9999,
51
+ })
52
+ const sql = prepareMock.mock.calls[0][0] as string
53
+ expect(sql).toContain('INSERT INTO public_idempotency_keys')
54
+ expect(sql).toContain('ON CONFLICT')
55
+ })
56
+
57
+ it('binds key, fingerprint, status, body, and expiresAt', async () => {
58
+ const { db, bindMock } = makeMockDb()
59
+ await new D1IdempotencyRepository(db).store({
60
+ key: 'idem-1', fingerprint: 'fp-x', responseStatus: 200, responseBody: '{"ok":1}', expiresAt: 8888,
61
+ })
62
+ const args = bindMock.mock.calls[0] as unknown[]
63
+ expect(args).toContain('idem-1')
64
+ expect(args).toContain('fp-x')
65
+ expect(args).toContain(200)
66
+ expect(args).toContain('{"ok":1}')
67
+ expect(args).toContain(8888)
68
+ })
69
+ })
70
+
71
+ describe('cleanup', () => {
72
+ it('calls DELETE with the expiration timestamp', async () => {
73
+ const { db, prepareMock, bindMock } = makeMockDb()
74
+ await new D1IdempotencyRepository(db).cleanup(12345)
75
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM public_idempotency_keys'))
76
+ expect(bindMock).toHaveBeenCalledWith(12345)
77
+ })
78
+ })
79
+ })
@@ -0,0 +1,15 @@
1
+ import type { IActivityLogger, ActivityLogEntry } from '@beechcms/core'
2
+
3
+ /**
4
+ * Test double for {@link IActivityLogger}.
5
+ *
6
+ * Captures every call into a public array so test assertions can verify
7
+ * audit-trail side effects without touching D1. Preserves insertion order.
8
+ */
9
+ export class InMemoryActivityLogger implements IActivityLogger {
10
+ public readonly entries: ActivityLogEntry[] = []
11
+
12
+ log(entry: ActivityLogEntry): void {
13
+ this.entries.push(entry)
14
+ }
15
+ }
@@ -0,0 +1,15 @@
1
+ import type { INotificationService, CreateNotificationInput } from '@beechcms/core'
2
+
3
+ /**
4
+ * Test double for {@link INotificationService}.
5
+ *
6
+ * Captures every call into a public array so tests can assert what would have
7
+ * been delivered without touching D1.
8
+ */
9
+ export class InMemoryNotificationService implements INotificationService {
10
+ public readonly receivedNotifications: CreateNotificationInput[] = []
11
+
12
+ notify(input: CreateNotificationInput): void {
13
+ this.receivedNotifications.push(input)
14
+ }
15
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { D1MediaRepository } from './media.repository.d1'
3
+
4
+ function makeMockDb(opts: { firstResult?: unknown; allResults?: unknown[] } = {}) {
5
+ const { firstResult = null, allResults = [] } = opts
6
+ const runMock = vi.fn().mockResolvedValue({ success: true })
7
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
8
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
9
+ // bind() returns the same interface so chains like prepare().bind().all() work.
10
+ // The statement also exposes first/all/run directly for queries that skip bind().
11
+ const bindMock = vi.fn(() => ({ run: runMock, first: firstMock, all: allMock }))
12
+ const stmt = { bind: bindMock, run: runMock, first: firstMock, all: allMock }
13
+ const prepareMock = vi.fn(() => stmt)
14
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock, allMock }
15
+ }
16
+
17
+ describe('D1MediaRepository', () => {
18
+ describe('trackUpload', () => {
19
+ it('calls INSERT INTO media_objects with correct bound values', async () => {
20
+ const { db, prepareMock, bindMock } = makeMockDb()
21
+ const obj = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 1024, uploaded_by: 'user-1' }
22
+ await new D1MediaRepository(db).trackUpload(obj)
23
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO media_objects'))
24
+ expect(bindMock).toHaveBeenCalledWith('k1', 'img.png', 'image/png', 1024, 'user-1')
25
+ })
26
+ })
27
+
28
+ describe('getByKey', () => {
29
+ it('returns the media object when found', async () => {
30
+ const row = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 512, uploaded_by: 'u1', created_at: 1000 }
31
+ const { db } = makeMockDb({ firstResult: row })
32
+ const result = await new D1MediaRepository(db).getByKey('k1')
33
+ expect(result).toEqual(row)
34
+ })
35
+
36
+ it('returns null when the key is not found', async () => {
37
+ const { db } = makeMockDb({ firstResult: null })
38
+ expect(await new D1MediaRepository(db).getByKey('ghost')).toBeNull()
39
+ })
40
+
41
+ it('passes the key as bound value', async () => {
42
+ const { db, bindMock } = makeMockDb()
43
+ await new D1MediaRepository(db).getByKey('my-key')
44
+ expect(bindMock).toHaveBeenCalledWith('my-key')
45
+ })
46
+ })
47
+
48
+ describe('untrack', () => {
49
+ it('calls DELETE FROM media_objects with the key', async () => {
50
+ const { db, prepareMock, bindMock } = makeMockDb()
51
+ await new D1MediaRepository(db).untrack('k1')
52
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM media_objects'))
53
+ expect(bindMock).toHaveBeenCalledWith('k1')
54
+ })
55
+ })
56
+
57
+ describe('list', () => {
58
+ it('returns items and total from D1', async () => {
59
+ const rows = [
60
+ { key: 'k1', filename: 'a.png', mime_type: 'image/png', size_bytes: 100, uploaded_by: 'u1', created_at: 1000 },
61
+ ]
62
+ const { db, allMock, firstMock } = makeMockDb()
63
+ // list() calls all() for items then first() for count (without bind)
64
+ allMock.mockResolvedValueOnce({ results: rows })
65
+ firstMock.mockResolvedValueOnce({ total: 1 })
66
+
67
+ const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
68
+ expect(result.items).toHaveLength(1)
69
+ expect(result.total).toBe(1)
70
+ })
71
+
72
+ it('returns empty items and 0 total when D1 returns nothing', async () => {
73
+ const { db, allMock, firstMock } = makeMockDb()
74
+ allMock.mockResolvedValueOnce({ results: [] })
75
+ firstMock.mockResolvedValueOnce(null) // count row is null → 0
76
+
77
+ const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
78
+ expect(result.items).toEqual([])
79
+ expect(result.total).toBe(0)
80
+ })
81
+
82
+ it('passes limit and offset as bound values for the list query', async () => {
83
+ const { db, bindMock, allMock, firstMock } = makeMockDb()
84
+ allMock.mockResolvedValueOnce({ results: [] })
85
+ firstMock.mockResolvedValueOnce({ total: 0 })
86
+
87
+ await new D1MediaRepository(db).list({ limit: 5, offset: 10 })
88
+ expect(bindMock).toHaveBeenCalledWith(5, 10)
89
+ })
90
+ })
91
+
92
+ describe('count', () => {
93
+ it('returns the count from D1', async () => {
94
+ const { db } = makeMockDb({ firstResult: { total: 42 } })
95
+ expect(await new D1MediaRepository(db).count()).toBe(42)
96
+ })
97
+
98
+ it('returns 0 when D1 returns null', async () => {
99
+ const { db } = makeMockDb({ firstResult: null })
100
+ expect(await new D1MediaRepository(db).count()).toBe(0)
101
+ })
102
+ })
103
+ })
@@ -10,7 +10,7 @@ export class D1MediaRepository implements MediaRepository {
10
10
  /**
11
11
  * Registers a new media upload in the database.
12
12
  */
13
- async trackUpload(mediaObject: MediaObject): Promise<void> {
13
+ async trackUpload(mediaObject: Omit<MediaObject, 'created_at'>): Promise<void> {
14
14
  await this.database.prepare(
15
15
  'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
16
16
  ).bind(
@@ -0,0 +1,22 @@
1
+ import type { HonoRequest } from "hono"
2
+
3
+ /** The header Cloudflare sets on every incoming request to the Worker. */
4
+ const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
5
+
6
+ /** The fallback value used when the IP header is absent (local dev, unit tests). */
7
+ const UNKNOWN_IP = "unknown"
8
+
9
+ /**
10
+ * Extracts the real client IP address from a Cloudflare Worker request.
11
+ *
12
+ * Cloudflare injects the cf-connecting-ip header on every request that
13
+ * passes through the edge. In local development (wrangler dev) or in
14
+ * unit tests the header may be absent, in which case the string "unknown"
15
+ * is returned so that rate-limiter keys remain non-empty and safe to use.
16
+ *
17
+ * Never derive security decisions from this value alone; treat it as a
18
+ * best-effort hint for rate-limiting and logging purposes only.
19
+ */
20
+ export function getClientIp(request: HonoRequest): string {
21
+ return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
22
+ }
@@ -0,0 +1,22 @@
1
+ import type { IIdGenerator } from '@beechcms/core'
2
+
3
+ const ID_PADDING_WIDTH = 4
4
+
5
+ /**
6
+ * Test-only IIdGenerator implementation. Each call returns a stable,
7
+ * monotonically increasing identifier (`test-id-0001`, `test-id-0002`, …)
8
+ * so snapshot assertions and insert-order checks remain deterministic
9
+ * across runs.
10
+ */
11
+ export class SequentialIdGenerator implements IIdGenerator {
12
+ private counter = 0
13
+
14
+ uuid(): string {
15
+ this.counter += 1
16
+ return `test-id-${String(this.counter).padStart(ID_PADDING_WIDTH, '0')}`
17
+ }
18
+
19
+ reset(): void {
20
+ this.counter = 0
21
+ }
22
+ }
@@ -12,13 +12,13 @@ export async function getBucketSize(client: S3Client, bucketName: string): Promi
12
12
 
13
13
  try {
14
14
  while (isTruncatedFlag) {
15
- const command = new ListObjectsV2Command({
15
+ const command: ListObjectsV2Command = new ListObjectsV2Command({
16
16
  Bucket: bucketName,
17
17
  ContinuationToken: continuationToken,
18
18
  })
19
19
 
20
- const response = (await client.send(command)) as ListObjectsV2CommandOutput
21
-
20
+ const response: ListObjectsV2CommandOutput = await client.send(command)
21
+
22
22
  if (response.Contents) {
23
23
  for (const obj of response.Contents) {
24
24
  totalSize += obj.Size ?? 0