@beechcms/api 0.5.0 → 0.6.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,53 +1,124 @@
1
- import type { Context } from 'hono'
2
-
3
- export interface PublicProblemDetailItem {
4
- field: string
5
- expected: string
6
- received: string
7
- message: string
8
- }
9
-
10
- type PublicProblemInput = {
11
- type: string
12
- title: string
13
- status: 400 | 401 | 403 | 404 | 405 | 409 | 422 | 429 | 500 | 501
14
- detail: string
15
- errors?: PublicProblemDetailItem[]
16
- }
17
-
18
- /**
19
- * TODO: UPDATE DOMAIN ONCE REGISTERED
20
- * Currently uses 'beechcms.dev' as a placeholder.
21
- * When a real domain is registered for BeechCMS, update the URL below
22
- * to point to the official API error documentation (RFC 9457).
23
- */
24
- function normalizeProblemType(type: string): string {
25
- if (type.startsWith('http://') || type.startsWith('https://')) {
26
- return type
27
- }
28
- return `https://beechcms.dev/problems/${type}`
29
- }
30
-
31
- export function internalErrorDetail(env: { ENV?: string }, error: unknown): string {
32
- if (env.ENV !== 'production' && error instanceof Error) return error.message
33
- return 'An unexpected error occurred.'
34
- }
35
-
36
- /**
37
- * Restituisce errori API in formato Problem Details (RFC 9457).
38
- */
39
- export function publicProblem(c: Context, input: PublicProblemInput): Response {
40
- const body: Record<string, unknown> = {
41
- type: normalizeProblemType(input.type),
42
- title: input.title,
43
- status: input.status,
44
- detail: input.detail,
45
- instance: c.req.path,
46
- }
47
- if (input.errors && input.errors.length > 0) {
48
- body.errors = input.errors
49
- }
50
- return c.json(body, input.status, {
51
- 'Content-Type': 'application/problem+json',
52
- })
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
+ 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,110 +1,114 @@
1
- import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
2
- import type { Context } from 'hono'
3
- import { cleanStr } from '../shared/query-utils'
4
- import { checkPublicOperation } from './access-policy'
5
- import { publicProblem, internalErrorDetail } from './problem-details'
6
- import { generateEntrySlug, slugify } from './slug-utils'
7
- import { sanitizePublicPayload } from './sanitize'
8
- import { parseIdempotencyKey, buildRequestFingerprint } from './idempotency'
9
- import { AppEnv } from '../types'
10
-
11
- function asRecord(value: unknown): Record<string, unknown> | null {
12
- return value !== null && typeof value === 'object' && !Array.isArray(value)
13
- ? (value as Record<string, unknown>)
14
- : null
15
- }
16
-
17
- function pickSlug(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
18
- const explicit = cleanStr(body.slug)
19
- if (explicit) return slugify(explicit)
20
- return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
21
- }
22
-
23
- export async function publicAddHandler(context: Context<AppEnv>) {
24
- const seedSlug = context.req.param('seed') ?? ''
25
- const seed = context.get('getSeed')(seedSlug)
26
- if (!seed) {
27
- return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
28
- }
29
-
30
- const access = checkPublicOperation(seed, 'add')
31
- if (!access.ok) {
32
- return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
33
- }
34
-
35
- let body: Record<string, unknown>
36
- try {
37
- const parsed = await context.req.json<unknown>()
38
- body = asRecord(parsed) ?? {}
39
- } catch {
40
- return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
41
- }
42
-
43
- const rawData = asRecord(body.data)
44
- if (!rawData || Object.keys(rawData).length === 0) {
45
- return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
46
- }
47
-
48
- const statusValue = body.status ?? 'draft'
49
- if (!isValidContentStatus(statusValue)) {
50
- return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
51
- }
52
-
53
- const sanitized = sanitizePublicPayload(seed, rawData, { operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true })
54
- if (!sanitized.ok) {
55
- if (sanitized.status === 422) {
56
- return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
57
- }
58
- return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
59
- }
60
-
61
- const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
62
- const finalSlug = pickSlug(body, sanitized.data) || context.get('idGenerator').uuid().slice(0, 8)
63
- const repository = context.get('repository')
64
- const idempotencyRepository = context.get('idempotencyRepository')
65
-
66
- try {
67
- const now = Math.floor(Date.now() / 1000)
68
- const fingerprint = await buildRequestFingerprint({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
69
- const idempotencyTtl = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
70
-
71
- if (idempotencyKey) {
72
- const existing = await idempotencyRepository.lookup(idempotencyKey)
73
- if (existing && existing.expiresAt >= now) {
74
- if (existing.fingerprint !== fingerprint) {
75
- return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
76
- }
77
- let parsedBody: unknown = null
78
- try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
79
- return context.json(parsedBody, existing.responseStatus as 201)
80
- }
81
- }
82
-
83
- const id = context.get('idGenerator').uuid()
84
-
85
- try {
86
- await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
87
- } catch (error) {
88
- if (error instanceof SlugConflictError) {
89
- return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
90
- }
91
- throw error
92
- }
93
-
94
- const responseBody = { success: true, id, slug: finalSlug }
95
- if (idempotencyKey) {
96
- await idempotencyRepository.store({ key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), expiresAt: now + idempotencyTtl })
97
- }
98
-
99
- context.get('notificationService').notify({
100
- title: `${seed.label}: New entry`,
101
- message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
102
- type: 'success',
103
- })
104
-
105
- return context.json(responseBody, 201)
106
- } catch (error) {
107
- console.error('Public add error:', error)
108
- return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: internalErrorDetail(context.env, error) })
109
- }
110
- }
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
+ }