@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
@@ -0,0 +1,98 @@
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 { ISeedRepository, SeedRecord, Seed } from '@beechcms/core'
7
+
8
+ export class D1SeedRepository implements ISeedRepository {
9
+ constructor(private readonly db: D1Database) {}
10
+
11
+ async listActive(): Promise<Seed[]> {
12
+ const rs = await this.db
13
+ .prepare(`SELECT definition FROM seeds WHERE status = 'active' ORDER BY created_at ASC`)
14
+ .all<{ definition: string }>()
15
+ const seeds: Seed[] = []
16
+ for (const r of rs.results ?? []) {
17
+ try { seeds.push(JSON.parse(r.definition) as Seed) } catch { /* skip corrupt */ }
18
+ }
19
+ return seeds
20
+ }
21
+
22
+ async listAll(): Promise<SeedRecord[]> {
23
+ const rs = await this.db
24
+ .prepare(`SELECT slug, definition, status, source, created_at, updated_at FROM seeds ORDER BY created_at ASC`)
25
+ .all<{ slug: string; definition: string; status: string; source: string; created_at: number; updated_at: number }>()
26
+ const out: SeedRecord[] = []
27
+ for (const r of rs.results ?? []) {
28
+ try {
29
+ out.push({
30
+ slug: r.slug,
31
+ definition: JSON.parse(r.definition) as Seed,
32
+ status: r.status as 'active' | 'deleted',
33
+ source: r.source as 'code' | 'runtime',
34
+ createdAt: r.created_at,
35
+ updatedAt: r.updated_at,
36
+ })
37
+ } catch { /* skip corrupt */ }
38
+ }
39
+ return out
40
+ }
41
+
42
+ async get(slug: string): Promise<SeedRecord | null> {
43
+ const r = await this.db
44
+ .prepare(`SELECT slug, definition, status, source, created_at, updated_at FROM seeds WHERE slug = ? LIMIT 1`)
45
+ .bind(slug)
46
+ .first<{ slug: string; definition: string; status: string; source: string; created_at: number; updated_at: number }>()
47
+ if (!r) return null
48
+ return {
49
+ slug: r.slug,
50
+ definition: JSON.parse(r.definition) as Seed,
51
+ status: r.status as 'active' | 'deleted',
52
+ source: r.source as 'code' | 'runtime',
53
+ createdAt: r.created_at,
54
+ updatedAt: r.updated_at,
55
+ }
56
+ }
57
+
58
+ async upsert(slug: string, definition: Seed, source: 'code' | 'runtime' = 'runtime'): Promise<void> {
59
+ const now = Math.floor(Date.now() / 1000)
60
+ await this.db
61
+ .prepare(`
62
+ INSERT INTO seeds (slug, definition, status, source, created_at, updated_at)
63
+ VALUES (?, ?, 'active', ?, ?, ?)
64
+ ON CONFLICT(slug) DO UPDATE SET
65
+ definition = excluded.definition,
66
+ status = 'active',
67
+ updated_at = excluded.updated_at
68
+ `)
69
+ .bind(slug, JSON.stringify(definition), source, now, now)
70
+ .run()
71
+ }
72
+
73
+ async softDelete(slug: string): Promise<void> {
74
+ const now = Math.floor(Date.now() / 1000)
75
+ await this.db
76
+ .prepare(`UPDATE seeds SET status = 'deleted', updated_at = ? WHERE slug = ?`)
77
+ .bind(now, slug)
78
+ .run()
79
+ }
80
+
81
+ async hardDelete(slug: string): Promise<void> {
82
+ await this.db.prepare(`DELETE FROM seeds WHERE slug = ?`).bind(slug).run()
83
+ }
84
+
85
+ async getRegistryVersion(): Promise<number> {
86
+ const r = await this.db
87
+ .prepare(`SELECT value FROM seed_meta WHERE id = 'registry_version' LIMIT 1`)
88
+ .first<{ value: string }>()
89
+ return r ? parseInt(r.value, 10) || 1 : 1
90
+ }
91
+
92
+ async bumpRegistryVersion(): Promise<number> {
93
+ const r = await this.db
94
+ .prepare(`UPDATE seed_meta SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE id = 'registry_version' RETURNING value`)
95
+ .first<{ value: string }>()
96
+ return r ? parseInt(r.value, 10) : 1
97
+ }
98
+ }
@@ -1,22 +1,33 @@
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
- }
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 type { IIdGenerator } from '@beechcms/core'
6
+
7
+ const ID_PADDING_WIDTH = 4
8
+
9
+ // Matches the format emitted by uuid() → 'test-id-XXXX'
10
+ const TEST_ID_REGEX = /^test-id-\d{4}$/
11
+
12
+ /**
13
+ * Test-only IIdGenerator implementation. Each call returns a stable,
14
+ * monotonically increasing identifier (`test-id-0001`, `test-id-0002`, …)
15
+ * so snapshot assertions and insert-order checks remain deterministic
16
+ * across runs.
17
+ */
18
+ export class SequentialIdGenerator implements IIdGenerator {
19
+ private counter = 0
20
+
21
+ uuid(): string {
22
+ this.counter += 1
23
+ return `test-id-${String(this.counter).padStart(ID_PADDING_WIDTH, '0')}`
24
+ }
25
+
26
+ isValid(value: unknown): value is string {
27
+ return typeof value === 'string' && TEST_ID_REGEX.test(value)
28
+ }
29
+
30
+ reset(): void {
31
+ this.counter = 0
32
+ }
33
+ }
@@ -0,0 +1,53 @@
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 { ISiteSettingsRepository, SiteSettings } from '@beechcms/core'
7
+
8
+ const DEFAULTS: SiteSettings = {
9
+ siteTitle: 'Beech CMS',
10
+ defaultLanguage: 'en',
11
+ timezone: 'Europe/Rome',
12
+ currency: 'EUR',
13
+ companyName: null,
14
+ companyWebsite: null,
15
+ companyAbbreviation: null,
16
+ }
17
+
18
+ export class D1SiteSettingsRepository implements ISiteSettingsRepository {
19
+ constructor(private readonly db: D1Database) {}
20
+
21
+ async getAll(): Promise<SiteSettings> {
22
+ const { results } = await this.db
23
+ .prepare('SELECT key, value FROM site_settings')
24
+ .all<{ key: string; value: string }>()
25
+
26
+ const map = new Map(results.map((r) => [r.key, r.value]))
27
+
28
+ return {
29
+ siteTitle: map.get('siteTitle') ?? DEFAULTS.siteTitle,
30
+ defaultLanguage: map.get('defaultLanguage') ?? DEFAULTS.defaultLanguage,
31
+ timezone: map.get('timezone') ?? DEFAULTS.timezone,
32
+ currency: map.get('currency') ?? DEFAULTS.currency,
33
+ companyName: map.get('companyName') ?? null,
34
+ companyWebsite: map.get('companyWebsite') ?? null,
35
+ companyAbbreviation: map.get('companyAbbreviation') ?? null,
36
+ }
37
+ }
38
+
39
+ async setMany(values: Partial<SiteSettings>): Promise<void> {
40
+ const entries = Object.entries(values).filter(([, v]) => v !== undefined) as [string, string | null][]
41
+ if (entries.length === 0) return
42
+
43
+ const stmts = entries.map(([key, value]) =>
44
+ this.db
45
+ .prepare(
46
+ 'INSERT INTO site_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
47
+ )
48
+ .bind(key, value),
49
+ )
50
+
51
+ await this.db.batch(stmts)
52
+ }
53
+ }
@@ -1,40 +1,41 @@
1
- import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
2
- import { Env } from '../../types'
3
- import { R2BindingBucket } from './r2-binding-bucket'
4
- import { S3Bucket } from './s3-bucket'
5
-
6
- class NullBucket implements BeechBucket {
7
- private fail(): never {
8
- throw new Error('No storage provider configured. Set MEDIA_BUCKET or R2 credentials.')
9
- }
10
- put(_key: string, _body: ArrayBuffer | Uint8Array | ReadableStream, _options?: PutBucketOptions): Promise<void> { return this.fail() }
11
- get(_key: string): Promise<GetBucketResult | null> { return this.fail() }
12
- delete(_key: string): Promise<void> { return this.fail() }
13
- head(_key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> { return this.fail() }
14
- getUrl(_key: string): string { return this.fail() }
15
- getTotalSize(): Promise<number> { return this.fail() }
16
- list(_options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> { return this.fail() }
17
- }
18
-
19
- /**
20
- * Creates the appropriate BeechBucket based on available bindings/credentials.
21
- * Uses capability detection: R2 binding takes precedence over S3-compatible credentials.
22
- */
23
- export function createBucketProvider(env: Env, baseUrl: string): BeechBucket {
24
- if (env.MEDIA_BUCKET) {
25
- return new R2BindingBucket(env.MEDIA_BUCKET, baseUrl)
26
- }
27
-
28
- if (env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.R2_ENDPOINT && env.R2_BUCKET_NAME) {
29
- return new S3Bucket({
30
- endpoint: env.R2_ENDPOINT,
31
- accessKeyId: env.R2_ACCESS_KEY_ID,
32
- secretAccessKey: env.R2_SECRET_ACCESS_KEY,
33
- bucketName: env.R2_BUCKET_NAME,
34
- baseUrl,
35
- cdnUrl: env.MEDIA_CDN_URL?.trim().replace(/\/$/, '') || undefined,
36
- })
37
- }
38
-
39
- return new NullBucket()
40
- }
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 { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
6
+ import { Env } from '../../types'
7
+ import { S3Bucket } from './s3-bucket'
8
+
9
+ class NullBucket implements BeechBucket {
10
+ private fail(): never {
11
+ throw new Error('Storage not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME.')
12
+ }
13
+ put(): Promise<void> { return this.fail() }
14
+ get(): Promise<GetBucketResult | null> { return this.fail() }
15
+ delete(): Promise<void> { return this.fail() }
16
+ head(): Promise<{ size: number } | null> { return this.fail() }
17
+ getUrl(): string { return this.fail() }
18
+ getTotalSize(): Promise<number> { return this.fail() }
19
+ list(): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> { return this.fail() }
20
+ presignPut(): Promise<string> { return this.fail() }
21
+ presignGet(): Promise<string> { return this.fail() }
22
+ }
23
+
24
+ /**
25
+ * Single storage path: S3-compatible HTTP API.
26
+ * Prod → Cloudflare R2 with S3 API token.
27
+ * Dev → MinIO container (or R2 staging bucket).
28
+ */
29
+ export function createBucketProvider(env: Env, baseUrl: string): BeechBucket {
30
+ if (env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.R2_ENDPOINT && env.R2_BUCKET_NAME) {
31
+ return new S3Bucket({
32
+ endpoint: env.R2_ENDPOINT,
33
+ accessKeyId: env.R2_ACCESS_KEY_ID,
34
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
35
+ bucketName: env.R2_BUCKET_NAME,
36
+ baseUrl,
37
+ cdnUrl: env.MEDIA_CDN_URL?.trim().replace(/\/$/, '') || undefined,
38
+ })
39
+ }
40
+ return new NullBucket()
41
+ }
@@ -1,163 +1,195 @@
1
- import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
2
- import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
3
-
4
- /**
5
- * Implementation of BeechBucket using S3-compatible API (AWS SDK).
6
- * Used for production when connecting to R2 or any other S3 provider via HTTP.
7
- */
8
- export class S3Bucket implements BeechBucket {
9
- private client: S3Client
10
- private bucketName: string
11
- private baseUrl: string
12
-
13
- private cdnUrl: string | null
14
-
15
- constructor(config: {
16
- endpoint: string,
17
- accessKeyId: string,
18
- secretAccessKey: string,
19
- bucketName: string,
20
- baseUrl: string,
21
- cdnUrl?: string,
22
- }) {
23
- this.client = new S3Client({
24
- region: 'auto',
25
- endpoint: config.endpoint,
26
- credentials: {
27
- accessKeyId: config.accessKeyId,
28
- secretAccessKey: config.secretAccessKey,
29
- },
30
- forcePathStyle: true,
31
- })
32
- this.bucketName = config.bucketName
33
- this.baseUrl = config.baseUrl
34
- this.cdnUrl = config.cdnUrl ?? null
35
- }
36
-
37
- async put(key: string, body: ArrayBuffer | Uint8Array | ReadableStream, options?: PutBucketOptions): Promise<void> {
38
- const command = new PutObjectCommand({
39
- Bucket: this.bucketName,
40
- Key: key,
41
- Body: body instanceof ReadableStream ? await this.streamToUint8Array(body) : new Uint8Array(body as ArrayBuffer),
42
- ContentType: options?.contentType,
43
- Metadata: options?.metadata
44
- })
45
- await this.client.send(command)
46
- }
47
-
48
- private async streamToUint8Array(stream: ReadableStream): Promise<Uint8Array> {
49
- const reader = stream.getReader()
50
- const chunks: Uint8Array[] = []
51
- let totalLength = 0
52
- while (true) {
53
- const { done, value } = await reader.read()
54
- if (done) break
55
- chunks.push(value)
56
- totalLength += value.length
57
- }
58
- const result = new Uint8Array(totalLength)
59
- let offset = 0
60
- for (const chunk of chunks) {
61
- result.set(chunk, offset)
62
- offset += chunk.length
63
- }
64
- return result
65
- }
66
-
67
- async get(key: string): Promise<GetBucketResult | null> {
68
- try {
69
- const command = new GetObjectCommand({
70
- Bucket: this.bucketName,
71
- Key: key
72
- })
73
- const response = await this.client.send(command)
74
-
75
- if (!response.Body) return null
76
-
77
- const rawBody = response.Body as any
78
- const body: ReadableStream = typeof rawBody.transformToWebStream === 'function'
79
- ? rawBody.transformToWebStream()
80
- : rawBody as ReadableStream
81
-
82
- return {
83
- body,
84
- contentType: response.ContentType,
85
- size: response.ContentLength ?? 0,
86
- metadata: response.Metadata
87
- }
88
- } catch (err: any) {
89
- if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.message === 'NoSuchKey') {
90
- return null
91
- }
92
- // Rethrow other errors (AccessDenied, Timeout, etc.)
93
- throw err
94
- }
95
- }
96
-
97
- async delete(key: string): Promise<void> {
98
- const command = new DeleteObjectCommand({
99
- Bucket: this.bucketName,
100
- Key: key
101
- })
102
- await this.client.send(command)
103
- }
104
-
105
- async head(key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> {
106
- try {
107
- const command = new HeadObjectCommand({
108
- Bucket: this.bucketName,
109
- Key: key
110
- })
111
- const response = await this.client.send(command)
112
- return {
113
- size: response.ContentLength ?? 0,
114
- contentType: response.ContentType,
115
- metadata: response.Metadata
116
- }
117
- } catch (err: any) {
118
- if (err.name === 'NoSuchKey' || err.name === 'NotFound') return null
119
- throw err
120
- }
121
- }
122
-
123
- getUrl(key: string): string {
124
- if (this.cdnUrl) {
125
- return `${this.cdnUrl}/${encodeURIComponent(key)}`
126
- }
127
- return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`
128
- }
129
-
130
- async getTotalSize(): Promise<number> {
131
- let total = 0
132
- let token: string | undefined
133
- do {
134
- const command = new ListObjectsV2Command({
135
- Bucket: this.bucketName,
136
- ContinuationToken: token
137
- })
138
- const response = await this.client.send(command)
139
- for (const obj of response.Contents ?? []) {
140
- total += obj.Size ?? 0
141
- }
142
- token = response.NextContinuationToken
143
- } while (token)
144
- return total
145
- }
146
-
147
- async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> {
148
- const command = new ListObjectsV2Command({
149
- Bucket: this.bucketName,
150
- Prefix: options?.prefix,
151
- MaxKeys: options?.limit,
152
- ContinuationToken: options?.cursor
153
- })
154
- const response = await this.client.send(command)
155
- return {
156
- objects: (response.Contents ?? []).map(obj => ({
157
- key: obj.Key ?? '',
158
- size: obj.Size ?? 0
159
- })),
160
- cursor: response.NextContinuationToken
161
- }
162
- }
163
- }
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 { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
6
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
7
+ import { BeechBucket, PutBucketOptions, GetBucketResult, PresignOptions } from '@beechcms/core'
8
+
9
+ /**
10
+ * Implementation of BeechBucket using S3-compatible API (AWS SDK).
11
+ * Used for production when connecting to R2 or any other S3 provider via HTTP.
12
+ */
13
+ export class S3Bucket implements BeechBucket {
14
+ private client: S3Client
15
+ private bucketName: string
16
+ private baseUrl: string
17
+
18
+ private cdnUrl: string | null
19
+
20
+ constructor(config: {
21
+ endpoint: string,
22
+ accessKeyId: string,
23
+ secretAccessKey: string,
24
+ bucketName: string,
25
+ baseUrl: string,
26
+ cdnUrl?: string,
27
+ }) {
28
+ // requestChecksumCalculation/responseChecksumValidation exist at runtime in AWS SDK v3
29
+ // but are not yet in the TypeScript types for this version. Casting via unknown to pass them
30
+ // through: without them the SDK adds x-amz-checksum-crc32 to presigned URLs, which causes
31
+ // R2 to reject PUT requests when the CRC32 of the uploaded file doesn't match.
32
+ this.client = new S3Client({
33
+ region: 'auto',
34
+ endpoint: config.endpoint,
35
+ credentials: {
36
+ accessKeyId: config.accessKeyId,
37
+ secretAccessKey: config.secretAccessKey,
38
+ },
39
+ forcePathStyle: true,
40
+ requestChecksumCalculation: 'when_required',
41
+ responseChecksumValidation: 'when_required',
42
+ } as unknown as import('@aws-sdk/client-s3').S3ClientConfig)
43
+ this.bucketName = config.bucketName
44
+ this.baseUrl = config.baseUrl
45
+ this.cdnUrl = config.cdnUrl ?? null
46
+ }
47
+
48
+ async put(key: string, body: ArrayBuffer | Uint8Array | ReadableStream, options?: PutBucketOptions): Promise<void> {
49
+ const command = new PutObjectCommand({
50
+ Bucket: this.bucketName,
51
+ Key: key,
52
+ Body: body instanceof ReadableStream ? await this.streamToUint8Array(body) : new Uint8Array(body as ArrayBuffer),
53
+ ContentType: options?.contentType,
54
+ Metadata: options?.metadata
55
+ })
56
+ await this.client.send(command)
57
+ }
58
+
59
+ private async streamToUint8Array(stream: ReadableStream): Promise<Uint8Array> {
60
+ const reader = stream.getReader()
61
+ const chunks: Uint8Array[] = []
62
+ let totalLength = 0
63
+ while (true) {
64
+ const { done, value } = await reader.read()
65
+ if (done) break
66
+ chunks.push(value)
67
+ totalLength += value.length
68
+ }
69
+ const result = new Uint8Array(totalLength)
70
+ let offset = 0
71
+ for (const chunk of chunks) {
72
+ result.set(chunk, offset)
73
+ offset += chunk.length
74
+ }
75
+ return result
76
+ }
77
+
78
+ async get(key: string): Promise<GetBucketResult | null> {
79
+ try {
80
+ const command = new GetObjectCommand({
81
+ Bucket: this.bucketName,
82
+ Key: key
83
+ })
84
+ const response = await this.client.send(command)
85
+
86
+ if (!response.Body) return null
87
+
88
+ const rawBody = response.Body as any
89
+ const body: ReadableStream = typeof rawBody.transformToWebStream === 'function'
90
+ ? rawBody.transformToWebStream()
91
+ : rawBody as ReadableStream
92
+
93
+ return {
94
+ body,
95
+ contentType: response.ContentType,
96
+ size: response.ContentLength ?? 0,
97
+ metadata: response.Metadata
98
+ }
99
+ } catch (err: any) {
100
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.message === 'NoSuchKey') {
101
+ return null
102
+ }
103
+ // Rethrow other errors (AccessDenied, Timeout, etc.)
104
+ throw err
105
+ }
106
+ }
107
+
108
+ async delete(key: string): Promise<void> {
109
+ const command = new DeleteObjectCommand({
110
+ Bucket: this.bucketName,
111
+ Key: key
112
+ })
113
+ await this.client.send(command)
114
+ }
115
+
116
+ async head(key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> {
117
+ try {
118
+ const command = new HeadObjectCommand({
119
+ Bucket: this.bucketName,
120
+ Key: key
121
+ })
122
+ const response = await this.client.send(command)
123
+ return {
124
+ size: response.ContentLength ?? 0,
125
+ contentType: response.ContentType,
126
+ metadata: response.Metadata
127
+ }
128
+ } catch (err: any) {
129
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound') return null
130
+ throw err
131
+ }
132
+ }
133
+
134
+ getUrl(key: string): string {
135
+ if (this.cdnUrl) {
136
+ return `${this.cdnUrl}/${encodeURIComponent(key)}`
137
+ }
138
+ return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`
139
+ }
140
+
141
+ async getTotalSize(): Promise<number> {
142
+ let total = 0
143
+ let token: string | undefined
144
+ do {
145
+ const command = new ListObjectsV2Command({
146
+ Bucket: this.bucketName,
147
+ ContinuationToken: token
148
+ })
149
+ const response = await this.client.send(command)
150
+ for (const obj of response.Contents ?? []) {
151
+ total += obj.Size ?? 0
152
+ }
153
+ token = response.NextContinuationToken
154
+ } while (token)
155
+ return total
156
+ }
157
+
158
+ async presignPut(key: string, options: PresignOptions): Promise<string> {
159
+ // ContentLength intentionally excluded: including it adds content-length to
160
+ // X-Amz-SignedHeaders, requiring the uploading client to send an exact
161
+ // Content-Length header. Some fetch implementations omit it, causing MinIO/R2
162
+ // to reject the PUT with 400. Size validation already happened server-side.
163
+ const command = new PutObjectCommand({
164
+ Bucket: this.bucketName,
165
+ Key: key,
166
+ ContentType: options.contentType,
167
+ })
168
+ return getSignedUrl(this.client, command, { expiresIn: options.expiresIn })
169
+ }
170
+
171
+ async presignGet(key: string, options: PresignOptions): Promise<string> {
172
+ const command = new GetObjectCommand({
173
+ Bucket: this.bucketName,
174
+ Key: key,
175
+ })
176
+ return getSignedUrl(this.client, command, { expiresIn: options.expiresIn })
177
+ }
178
+
179
+ async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> {
180
+ const command = new ListObjectsV2Command({
181
+ Bucket: this.bucketName,
182
+ Prefix: options?.prefix,
183
+ MaxKeys: options?.limit,
184
+ ContinuationToken: options?.cursor
185
+ })
186
+ const response = await this.client.send(command)
187
+ return {
188
+ objects: (response.Contents ?? []).map(obj => ({
189
+ key: obj.Key ?? '',
190
+ size: obj.Size ?? 0
191
+ })),
192
+ cursor: response.NextContinuationToken
193
+ }
194
+ }
195
+ }