@beechcms/api 0.6.0-preview.1 → 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 (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
@@ -1,46 +1,46 @@
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 { resolvePolicies } from '@beechcms/core'
6
- import type { Seed } from '@beechcms/core'
7
-
8
- const SYSTEM_FIELDS = ['id', 'slug', 'status', 'created_at', 'updated_at']
9
- const IDENTITY_FIELDS = ['id', 'slug']
10
-
11
- function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
12
- const result: Record<string, unknown> = {}
13
-
14
- for (const key of SYSTEM_FIELDS) {
15
- if (key in data) result[key] = data[key]
16
- }
17
-
18
- for (const branch of seed.branches) {
19
- const value = data[branch.alias]
20
- const { public: isPublic, visibility } = resolvePolicies(branch)
21
- if (!isPublic) continue
22
- if (visibility === 'hidden') continue
23
- result[branch.alias] = visibility === 'masked' && typeof value === 'string' && value.length > 0
24
- ? '••••••••'
25
- : value
26
- }
27
- return result
28
- }
29
-
30
- export function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
31
- const projected = applyPublicPolicies(data, seed)
32
- const requestedFields = (fieldsParam ?? '').split(',').map(f => f.trim()).filter(Boolean)
33
-
34
- if (requestedFields.length === 0) return projected
35
-
36
- const filtered: Record<string, unknown> = {}
37
- for (const field of requestedFields) {
38
- if (field in projected) filtered[field] = projected[field]
39
- }
40
-
41
- for (const key of IDENTITY_FIELDS) {
42
- if (key in projected && !filtered[key]) filtered[key] = projected[key]
43
- }
44
-
45
- return filtered
46
- }
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 { resolvePolicies } from '@beechcms/core'
6
+ import type { Seed } from '@beechcms/core'
7
+
8
+ const SYSTEM_FIELDS = ['id', 'slug', 'status', 'created_at', 'updated_at']
9
+ const IDENTITY_FIELDS = ['id', 'slug']
10
+
11
+ function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
12
+ const result: Record<string, unknown> = {}
13
+
14
+ for (const key of SYSTEM_FIELDS) {
15
+ if (key in data) result[key] = data[key]
16
+ }
17
+
18
+ for (const branch of seed.branches) {
19
+ const value = data[branch.alias]
20
+ const { public: isPublic, visibility } = resolvePolicies(branch)
21
+ if (!isPublic) continue
22
+ if (visibility === 'hidden') continue
23
+ result[branch.alias] = visibility === 'masked' && typeof value === 'string' && value.length > 0
24
+ ? '••••••••'
25
+ : value
26
+ }
27
+ return result
28
+ }
29
+
30
+ export function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
31
+ const projected = applyPublicPolicies(data, seed)
32
+ const requestedFields = (fieldsParam ?? '').split(',').map(f => f.trim()).filter(Boolean)
33
+
34
+ if (requestedFields.length === 0) return projected
35
+
36
+ const filtered: Record<string, unknown> = {}
37
+ for (const field of requestedFields) {
38
+ if (field in projected) filtered[field] = projected[field]
39
+ }
40
+
41
+ for (const key of IDENTITY_FIELDS) {
42
+ if (key in projected && !filtered[key]) filtered[key] = projected[key]
43
+ }
44
+
45
+ return filtered
46
+ }
@@ -1,23 +1,23 @@
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 { sha256hex } from '@beechcms/core'
6
-
7
- export function parseIdempotencyKey(rawValue: string | undefined): string | null {
8
- if (!rawValue) return null
9
- const key = rawValue.trim()
10
- if (!key || key.length > 128) return null
11
- return key
12
- }
13
-
14
- type FingerprintInput = {
15
- seedSlug: string
16
- statusValue: unknown
17
- slug: string | null
18
- data: Record<string, unknown>
19
- }
20
-
21
- export function buildRequestFingerprint(input: FingerprintInput): Promise<string> {
22
- return sha256hex(JSON.stringify({ seedSlug: input.seedSlug, statusValue: input.statusValue, slug: input.slug, data: input.data }))
23
- }
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 { sha256hex } from '@beechcms/core'
6
+
7
+ export function parseIdempotencyKey(rawValue: string | undefined): string | null {
8
+ if (!rawValue) return null
9
+ const key = rawValue.trim()
10
+ if (!key || key.length > 128) return null
11
+ return key
12
+ }
13
+
14
+ type FingerprintInput = {
15
+ seedSlug: string
16
+ statusValue: unknown
17
+ slug: string | null
18
+ data: Record<string, unknown>
19
+ }
20
+
21
+ export function buildRequestFingerprint(input: FingerprintInput): Promise<string> {
22
+ return sha256hex(JSON.stringify({ seedSlug: input.seedSlug, statusValue: input.statusValue, slug: input.slug, data: input.data }))
23
+ }
@@ -1,16 +1,16 @@
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
- export { apiKeyMiddleware } from './api-key-middleware'
6
- export { publicRateLimitMiddleware } from './rate-limit-middleware'
7
- export { publicRoutes } from './public-routes'
8
- export { PUBLIC_ERRORS } from './public-errors'
9
- export { sanitizePublicPayload } from './sanitize'
10
- export { generateEntrySlug, slugify } from './slug-utils'
11
- export { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
12
- export { parseLatestCount, parsePublicPagination } from './query-builder'
13
- export { publicReadHandler } from './public-read'
14
- export { publicAddHandler } from './public-add'
15
- export { publicEditHandler } from './public-edit'
16
-
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
+ export { apiKeyMiddleware } from './api-key-middleware'
6
+ export { publicRateLimitMiddleware } from './rate-limit-middleware'
7
+ export { publicRoutes } from './public-routes'
8
+ export { PUBLIC_ERRORS } from './public-errors'
9
+ export { sanitizePublicPayload } from './sanitize'
10
+ export { generateEntrySlug, slugify } from './slug-utils'
11
+ export { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
12
+ export { parseLatestCount, parsePublicPagination } from './query-builder'
13
+ export { publicReadHandler } from './public-read'
14
+ export { publicAddHandler } from './public-add'
15
+ export { publicEditHandler } from './public-edit'
16
+
@@ -1,124 +1,124 @@
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 { Context } from 'hono'
6
-
7
- // ──────────────────────────────────────────────────────────────────────────────
8
- // FK / SQLite error detection
9
- // ──────────────────────────────────────────────────────────────────────────────
10
-
11
- type FkViolationKind = 'relation-in-use' | 'relation-target-not-found' | 'foreign-key-violation'
12
-
13
- function isFkError(error: unknown): boolean {
14
- if (!(error instanceof Error)) return false
15
- // D1 surfaces the error in .message
16
- if (/FOREIGN KEY constraint failed/i.test(error.message)) return true
17
- // Some runtimes expose cause.code
18
- const cause = (error as { cause?: { code?: string } }).cause
19
- if (cause?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') return true
20
- return false
21
- }
22
-
23
- /**
24
- * Classify a SQLite FK error by HTTP method.
25
- * - DELETE → the referenced row is still used → relation-in-use
26
- * - POST/PUT/PATCH → referenced parent does not exist → relation-target-not-found
27
- * - anything else → generic foreign-key-violation
28
- */
29
- function classifyFkError(method: string): FkViolationKind {
30
- const m = method.toUpperCase()
31
- if (m === 'DELETE') return 'relation-in-use'
32
- if (m === 'POST' || m === 'PUT' || m === 'PATCH') return 'relation-target-not-found'
33
- return 'foreign-key-violation'
34
- }
35
-
36
- const FK_PROBLEM: Record<FkViolationKind, { title: string; status: 409 | 422; detail: string }> = {
37
- 'relation-in-use': {
38
- title: 'Relation In Use',
39
- status: 409,
40
- detail: 'The record cannot be deleted because it is referenced by one or more related entries.',
41
- },
42
- 'relation-target-not-found': {
43
- title: 'Relation Target Not Found',
44
- status: 422,
45
- detail: 'The provided relation id does not match any existing record in the referenced collection.',
46
- },
47
- 'foreign-key-violation': {
48
- title: 'Foreign Key Violation',
49
- status: 409,
50
- detail: 'The operation violates a referential integrity constraint.',
51
- },
52
- }
53
-
54
- /**
55
- * Returns a Problem+JSON response when the error is a SQLite FK violation,
56
- * or null when the error is unrelated (let the caller handle it).
57
- *
58
- * @param c Hono context
59
- * @param error The caught error
60
- * @param method HTTP method of the current request (e.g. 'DELETE', 'POST')
61
- */
62
- export function fkProblemOrNull(c: Context, error: unknown, method: string): Response | null {
63
- if (!isFkError(error)) return null
64
- const kind = classifyFkError(method)
65
- const meta = FK_PROBLEM[kind]
66
- return publicProblem(c, {
67
- type: `https://beechcms.dev/problems/${kind}`,
68
- title: meta.title,
69
- status: meta.status,
70
- detail: meta.detail,
71
- })
72
- }
73
-
74
- export interface PublicProblemDetailItem {
75
- field: string
76
- expected: string
77
- received: string
78
- message: string
79
- }
80
-
81
- type PublicProblemInput = {
82
- type: string
83
- title: string
84
- status: 400 | 401 | 403 | 404 | 405 | 409 | 422 | 429 | 500 | 501
85
- detail: string
86
- errors?: PublicProblemDetailItem[]
87
- }
88
-
89
- /**
90
- * TODO: UPDATE DOMAIN ONCE REGISTERED
91
- * Currently uses 'beechcms.dev' as a placeholder.
92
- * When a real domain is registered for BeechCMS, update the URL below
93
- * to point to the official API error documentation (RFC 9457).
94
- */
95
- function normalizeProblemType(type: string): string {
96
- if (type.startsWith('http://') || type.startsWith('https://')) {
97
- return type
98
- }
99
- return `https://beechcms.dev/problems/${type}`
100
- }
101
-
102
- export function internalErrorDetail(env: { ENV?: string }, error: unknown): string {
103
- if (env.ENV !== 'production' && error instanceof Error) return error.message
104
- return 'An unexpected error occurred.'
105
- }
106
-
107
- /**
108
- * Restituisce errori API in formato Problem Details (RFC 9457).
109
- */
110
- export function publicProblem(c: Context, input: PublicProblemInput): Response {
111
- const body: Record<string, unknown> = {
112
- type: normalizeProblemType(input.type),
113
- title: input.title,
114
- status: input.status,
115
- detail: input.detail,
116
- instance: c.req.path,
117
- }
118
- if (input.errors && input.errors.length > 0) {
119
- body.errors = input.errors
120
- }
121
- return c.json(body, input.status, {
122
- 'Content-Type': 'application/problem+json',
123
- })
124
- }
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 { Context } from 'hono'
6
+
7
+ // ──────────────────────────────────────────────────────────────────────────────
8
+ // FK / SQLite error detection
9
+ // ──────────────────────────────────────────────────────────────────────────────
10
+
11
+ type FkViolationKind = 'relation-in-use' | 'relation-target-not-found' | 'foreign-key-violation'
12
+
13
+ function isFkError(error: unknown): boolean {
14
+ if (!(error instanceof Error)) return false
15
+ // D1 surfaces the error in .message
16
+ if (/FOREIGN KEY constraint failed/i.test(error.message)) return true
17
+ // Some runtimes expose cause.code
18
+ const cause = (error as { cause?: { code?: string } }).cause
19
+ if (cause?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') return true
20
+ return false
21
+ }
22
+
23
+ /**
24
+ * Classify a SQLite FK error by HTTP method.
25
+ * - DELETE → the referenced row is still used → relation-in-use
26
+ * - POST/PUT/PATCH → referenced parent does not exist → relation-target-not-found
27
+ * - anything else → generic foreign-key-violation
28
+ */
29
+ function classifyFkError(method: string): FkViolationKind {
30
+ const m = method.toUpperCase()
31
+ if (m === 'DELETE') return 'relation-in-use'
32
+ if (m === 'POST' || m === 'PUT' || m === 'PATCH') return 'relation-target-not-found'
33
+ return 'foreign-key-violation'
34
+ }
35
+
36
+ const FK_PROBLEM: Record<FkViolationKind, { title: string; status: 409 | 422; detail: string }> = {
37
+ 'relation-in-use': {
38
+ title: 'Relation In Use',
39
+ status: 409,
40
+ detail: 'The record cannot be deleted because it is referenced by one or more related entries.',
41
+ },
42
+ 'relation-target-not-found': {
43
+ title: 'Relation Target Not Found',
44
+ status: 422,
45
+ detail: 'The provided relation id does not match any existing record in the referenced collection.',
46
+ },
47
+ 'foreign-key-violation': {
48
+ title: 'Foreign Key Violation',
49
+ status: 409,
50
+ detail: 'The operation violates a referential integrity constraint.',
51
+ },
52
+ }
53
+
54
+ /**
55
+ * Returns a Problem+JSON response when the error is a SQLite FK violation,
56
+ * or null when the error is unrelated (let the caller handle it).
57
+ *
58
+ * @param c Hono context
59
+ * @param error The caught error
60
+ * @param method HTTP method of the current request (e.g. 'DELETE', 'POST')
61
+ */
62
+ export function fkProblemOrNull(c: Context, error: unknown, method: string): Response | null {
63
+ if (!isFkError(error)) return null
64
+ const kind = classifyFkError(method)
65
+ const meta = FK_PROBLEM[kind]
66
+ return publicProblem(c, {
67
+ type: `https://beechcms.dev/problems/${kind}`,
68
+ title: meta.title,
69
+ status: meta.status,
70
+ detail: meta.detail,
71
+ })
72
+ }
73
+
74
+ export interface PublicProblemDetailItem {
75
+ field: string
76
+ expected: string
77
+ received: string
78
+ message: string
79
+ }
80
+
81
+ type PublicProblemInput = {
82
+ type: string
83
+ title: string
84
+ status: 400 | 401 | 403 | 404 | 405 | 409 | 422 | 429 | 500 | 501
85
+ detail: string
86
+ errors?: PublicProblemDetailItem[]
87
+ }
88
+
89
+ /**
90
+ * TODO: UPDATE DOMAIN ONCE REGISTERED
91
+ * Currently uses 'beechcms.dev' as a placeholder.
92
+ * When a real domain is registered for BeechCMS, update the URL below
93
+ * to point to the official API error documentation (RFC 9457).
94
+ */
95
+ function normalizeProblemType(type: string): string {
96
+ if (type.startsWith('http://') || type.startsWith('https://')) {
97
+ return type
98
+ }
99
+ return `https://beechcms.dev/problems/${type}`
100
+ }
101
+
102
+ export function internalErrorDetail(env: { ENV?: string }, error: unknown): string {
103
+ if (env.ENV !== 'production' && error instanceof Error) return error.message
104
+ return 'An unexpected error occurred.'
105
+ }
106
+
107
+ /**
108
+ * Restituisce errori API in formato Problem Details (RFC 9457).
109
+ */
110
+ export function publicProblem(c: Context, input: PublicProblemInput): Response {
111
+ const body: Record<string, unknown> = {
112
+ type: normalizeProblemType(input.type),
113
+ title: input.title,
114
+ status: input.status,
115
+ detail: input.detail,
116
+ instance: c.req.path,
117
+ }
118
+ if (input.errors && input.errors.length > 0) {
119
+ body.errors = input.errors
120
+ }
121
+ return c.json(body, input.status, {
122
+ 'Content-Type': 'application/problem+json',
123
+ })
124
+ }
@@ -1,114 +1,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
- import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
6
- import type { Context } from 'hono'
7
- import { cleanStr } from '../shared/query-utils'
8
- import { checkPublicOperation } from './access-policy'
9
- import { publicProblem, internalErrorDetail } from './problem-details'
10
- import { generateEntrySlug, slugify } from './slug-utils'
11
- import { sanitizePublicPayload } from './sanitize'
12
- import { parseIdempotencyKey, buildRequestFingerprint } from './idempotency'
13
- import { AppEnv } from '../types'
14
-
15
- function asRecord(value: unknown): Record<string, unknown> | null {
16
- return value !== null && typeof value === 'object' && !Array.isArray(value)
17
- ? (value as Record<string, unknown>)
18
- : null
19
- }
20
-
21
- function pickSlug(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
22
- const explicit = cleanStr(body.slug)
23
- if (explicit) return slugify(explicit)
24
- return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
25
- }
26
-
27
- export async function publicAddHandler(context: Context<AppEnv>) {
28
- const seedSlug = context.req.param('seed') ?? ''
29
- const seed = context.get('getSeed')(seedSlug)
30
- if (!seed) {
31
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
32
- }
33
-
34
- const access = checkPublicOperation(seed, 'add')
35
- if (!access.ok) {
36
- return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
37
- }
38
-
39
- let body: Record<string, unknown>
40
- try {
41
- const parsed = await context.req.json<unknown>()
42
- body = asRecord(parsed) ?? {}
43
- } catch {
44
- return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
45
- }
46
-
47
- const rawData = asRecord(body.data)
48
- if (!rawData || Object.keys(rawData).length === 0) {
49
- return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
50
- }
51
-
52
- const statusValue = body.status ?? 'draft'
53
- if (!isValidContentStatus(statusValue)) {
54
- return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
55
- }
56
-
57
- const sanitized = sanitizePublicPayload(seed, rawData, { operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true })
58
- if (!sanitized.ok) {
59
- if (sanitized.status === 422) {
60
- return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
61
- }
62
- return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
63
- }
64
-
65
- const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
66
- const finalSlug = pickSlug(body, sanitized.data) || context.get('idGenerator').uuid().slice(0, 8)
67
- const repository = context.get('repository')
68
- const idempotencyRepository = context.get('idempotencyRepository')
69
-
70
- try {
71
- const now = Math.floor(Date.now() / 1000)
72
- const fingerprint = await buildRequestFingerprint({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
73
- const idempotencyTtl = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
74
-
75
- if (idempotencyKey) {
76
- const existing = await idempotencyRepository.lookup(idempotencyKey)
77
- if (existing && existing.expiresAt >= now) {
78
- if (existing.fingerprint !== fingerprint) {
79
- return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
80
- }
81
- let parsedBody: unknown = null
82
- try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
83
- return context.json(parsedBody, existing.responseStatus as 201)
84
- }
85
- }
86
-
87
- const id = context.get('idGenerator').uuid()
88
-
89
- try {
90
- await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
91
- } catch (error) {
92
- if (error instanceof SlugConflictError) {
93
- return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
94
- }
95
- throw error
96
- }
97
-
98
- const responseBody = { success: true, id, slug: finalSlug }
99
- if (idempotencyKey) {
100
- await idempotencyRepository.store({ key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), expiresAt: now + idempotencyTtl })
101
- }
102
-
103
- context.get('notificationService').notify({
104
- title: `${seed.label}: New entry`,
105
- message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
106
- type: 'success',
107
- })
108
-
109
- return context.json(responseBody, 201)
110
- } catch (error) {
111
- console.error('Public add error:', error)
112
- return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: internalErrorDetail(context.env, error) })
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
+ import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
6
+ import type { Context } from 'hono'
7
+ import { cleanStr } from '../shared/query-utils'
8
+ import { checkPublicOperation } from './access-policy'
9
+ import { publicProblem, internalErrorDetail } from './problem-details'
10
+ import { generateEntrySlug, slugify } from './slug-utils'
11
+ import { sanitizePublicPayload } from './sanitize'
12
+ import { parseIdempotencyKey, buildRequestFingerprint } from './idempotency'
13
+ import { AppEnv } from '../types'
14
+
15
+ function asRecord(value: unknown): Record<string, unknown> | null {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
17
+ ? (value as Record<string, unknown>)
18
+ : null
19
+ }
20
+
21
+ function pickSlug(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
22
+ const explicit = cleanStr(body.slug)
23
+ if (explicit) return slugify(explicit)
24
+ return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
25
+ }
26
+
27
+ export async function publicAddHandler(context: Context<AppEnv>) {
28
+ const seedSlug = context.req.param('seed') ?? ''
29
+ const seed = context.get('getSeed')(seedSlug)
30
+ if (!seed) {
31
+ return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
32
+ }
33
+
34
+ const access = checkPublicOperation(seed, 'add')
35
+ if (!access.ok) {
36
+ return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
37
+ }
38
+
39
+ let body: Record<string, unknown>
40
+ try {
41
+ const parsed = await context.req.json<unknown>()
42
+ body = asRecord(parsed) ?? {}
43
+ } catch {
44
+ return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
45
+ }
46
+
47
+ const rawData = asRecord(body.data)
48
+ if (!rawData || Object.keys(rawData).length === 0) {
49
+ return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
50
+ }
51
+
52
+ const statusValue = body.status ?? 'draft'
53
+ if (!isValidContentStatus(statusValue)) {
54
+ return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
55
+ }
56
+
57
+ const sanitized = sanitizePublicPayload(seed, rawData, { operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true })
58
+ if (!sanitized.ok) {
59
+ if (sanitized.status === 422) {
60
+ return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
61
+ }
62
+ return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
63
+ }
64
+
65
+ const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
66
+ const finalSlug = pickSlug(body, sanitized.data) || context.get('idGenerator').uuid().slice(0, 8)
67
+ const repository = context.get('repository')
68
+ const idempotencyRepository = context.get('idempotencyRepository')
69
+
70
+ try {
71
+ const now = Math.floor(Date.now() / 1000)
72
+ const fingerprint = await buildRequestFingerprint({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
73
+ const idempotencyTtl = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
74
+
75
+ if (idempotencyKey) {
76
+ const existing = await idempotencyRepository.lookup(idempotencyKey)
77
+ if (existing && existing.expiresAt >= now) {
78
+ if (existing.fingerprint !== fingerprint) {
79
+ return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
80
+ }
81
+ let parsedBody: unknown = null
82
+ try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
83
+ return context.json(parsedBody, existing.responseStatus as 201)
84
+ }
85
+ }
86
+
87
+ const id = context.get('idGenerator').uuid()
88
+
89
+ try {
90
+ await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
91
+ } catch (error) {
92
+ if (error instanceof SlugConflictError) {
93
+ return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
94
+ }
95
+ throw error
96
+ }
97
+
98
+ const responseBody = { success: true, id, slug: finalSlug }
99
+ if (idempotencyKey) {
100
+ await idempotencyRepository.store({ key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), expiresAt: now + idempotencyTtl })
101
+ }
102
+
103
+ context.get('notificationService').notify({
104
+ title: `${seed.label}: New entry`,
105
+ message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
106
+ type: 'success',
107
+ })
108
+
109
+ return context.json(responseBody, 201)
110
+ } catch (error) {
111
+ console.error('Public add error:', error)
112
+ return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: internalErrorDetail(context.env, error) })
113
+ }
114
+ }