@beechcms/api 0.4.0-preview.9 → 0.4.1

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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
@@ -0,0 +1,211 @@
1
+ import { Context } from 'hono'
2
+ import {
3
+ slugify,
4
+ isValidContentStatus,
5
+ validateAndSanitizeSeedPayload,
6
+ resolvePolicies,
7
+ EntryNotFoundError,
8
+ SlugConflictError
9
+ } from '@beechcms/core'
10
+ import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
11
+ import { publicProblem } from '../../../public/problem-details'
12
+ import { CONTENT_ERRORS } from '../constants'
13
+ import { cleanStr } from '../../../shared/query-utils'
14
+ import { AppEnv } from '../../../types'
15
+
16
+ function normalizeBody(raw: unknown): Record<string, unknown> {
17
+ return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
18
+ }
19
+
20
+ function contentValidationProblem(
21
+ context: Context,
22
+ details: Array<{ field: string; expected: string; received: string; message: string }>
23
+ ) {
24
+ return publicProblem(context, {
25
+ type: 'content-validation-failed',
26
+ title: 'Bad Request',
27
+ status: 400,
28
+ detail: 'Validation failed',
29
+ errors: details
30
+ })
31
+ }
32
+
33
+ export async function updateHandler(context: Context<AppEnv>) {
34
+ const slug = context.req.param('slug')
35
+ const id = context.req.param('id')
36
+ if (!slug || !id) {
37
+ return publicProblem(context, {
38
+ type: 'content-invalid-slug-or-id',
39
+ title: 'Bad Request',
40
+ status: 400,
41
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
42
+ })
43
+ }
44
+
45
+ const seed = context.get('getSeed')(slug)
46
+ if (!seed) {
47
+ return publicProblem(context, {
48
+ type: 'content-seed-not-found',
49
+ title: 'Not Found',
50
+ status: 404,
51
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
52
+ })
53
+ }
54
+
55
+ let body: Record<string, unknown>
56
+ try {
57
+ body = normalizeBody(await context.req.json<unknown>())
58
+ } catch {
59
+ return publicProblem(context, {
60
+ type: 'content-invalid-json',
61
+ title: 'Bad Request',
62
+ status: 400,
63
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
64
+ })
65
+ }
66
+
67
+ const bodyForData = { ...body }
68
+ delete bodyForData.slug
69
+ delete bodyForData.status
70
+
71
+ try {
72
+ const repository = context.get('repository')
73
+ // We need current data to handle slug and status if not provided,
74
+ // and to verify existence.
75
+ const current = await repository.findById(seed, id)
76
+
77
+ const newSlug = body.slug !== undefined ? slugify(String(body.slug)) : (current.slug as string)
78
+ const newStatus = body.status !== undefined ? (cleanStr(body.status) ?? current.status as string) : current.status as string
79
+
80
+ if (!isValidContentStatus(newStatus)) {
81
+ return publicProblem(context, {
82
+ type: 'content-invalid-status',
83
+ title: 'Bad Request',
84
+ status: 400,
85
+ detail: 'Invalid status. Allowed values are: draft, review, published'
86
+ })
87
+ }
88
+
89
+ if (!newSlug) {
90
+ return publicProblem(context, {
91
+ type: 'content-missing-slug',
92
+ title: 'Bad Request',
93
+ status: 400,
94
+ detail: 'Missing required field: slug'
95
+ })
96
+ }
97
+
98
+ let mergedData: Record<string, unknown> = {}
99
+
100
+ if (Object.keys(bodyForData).length > 0) {
101
+ const sensitiveAliases = Object.keys(bodyForData).filter((alias) => {
102
+ const branch = seed.branches.find((b) => b.alias === alias)
103
+ return branch != null && resolvePolicies(branch).privacy !== 'plain'
104
+ })
105
+
106
+ if (sensitiveAliases.length > 0) {
107
+ return publicProblem(context, {
108
+ type: 'content-sensitive-field-edit',
109
+ title: 'Unprocessable Entity',
110
+ status: 422,
111
+ detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
112
+ })
113
+ }
114
+
115
+ const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
116
+ operation: 'update',
117
+ allowNull: true,
118
+ requireAtLeastOneValidField: true,
119
+ enforceRequiredFields: true,
120
+ })
121
+
122
+ if (validation.dangerousFields.length > 0) {
123
+ return publicProblem(context, {
124
+ type: 'content-dangerous-content',
125
+ title: 'Unprocessable Entity',
126
+ status: 422,
127
+ detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'`
128
+ })
129
+ }
130
+
131
+ if (validation.details.length > 0) return contentValidationProblem(context, validation.details)
132
+
133
+ let privacyPatch: Record<string, unknown>
134
+ try {
135
+ privacyPatch = await applyPrivacy(validation.data, seed)
136
+ } catch (error) {
137
+ if (error instanceof PrivacyPolicyError) {
138
+ return publicProblem(context, {
139
+ type: 'content-policy-not-implemented',
140
+ title: 'Not Implemented',
141
+ status: 501,
142
+ detail: error.message
143
+ })
144
+ }
145
+ throw error
146
+ }
147
+
148
+ // Remove null values from patch (patch semantics: null = leave unchanged)
149
+ for (const [k, v] of Object.entries(privacyPatch)) {
150
+ if (v !== null) mergedData[k] = v
151
+ }
152
+ }
153
+
154
+ if (newSlug !== current.slug) {
155
+ if (await repository.existsSlug(seed, newSlug, id)) {
156
+ return publicProblem(context, {
157
+ type: 'content-slug-conflict',
158
+ title: 'Conflict',
159
+ status: 409,
160
+ detail: CONTENT_ERRORS.SLUG_CONFLICT
161
+ })
162
+ }
163
+ // Add slug to mergedData if changed
164
+ mergedData.slug = newSlug
165
+ }
166
+
167
+ await repository.update(seed, id, mergedData, newStatus)
168
+
169
+ const jwtPayload = context.get('jwtPayload')
170
+ const title = mergedData.title || mergedData.name || newSlug
171
+
172
+ context.get('activityLogger').log({
173
+ action: 'update',
174
+ entityType: 'content',
175
+ entityId: id,
176
+ entitySlug: slug,
177
+ details: { title },
178
+ actor: {
179
+ id: jwtPayload.sub,
180
+ email: jwtPayload.email ?? 'unknown',
181
+ name: jwtPayload.name ?? null,
182
+ },
183
+ })
184
+
185
+ return context.json({ success: true })
186
+ } catch (error) {
187
+ if (error instanceof EntryNotFoundError) {
188
+ return publicProblem(context, {
189
+ type: 'content-not-found',
190
+ title: 'Not Found',
191
+ status: 404,
192
+ detail: CONTENT_ERRORS.NOT_FOUND
193
+ })
194
+ }
195
+ if (error instanceof SlugConflictError) {
196
+ return publicProblem(context, {
197
+ type: 'content-slug-conflict',
198
+ title: 'Conflict',
199
+ status: 409,
200
+ detail: CONTENT_ERRORS.SLUG_CONFLICT
201
+ })
202
+ }
203
+ console.error('Content update error:', error)
204
+ return publicProblem(context, {
205
+ type: 'content-database-error',
206
+ title: 'Internal Server Error',
207
+ status: 500,
208
+ detail: CONTENT_ERRORS.DATABASE_ERROR
209
+ })
210
+ }
211
+ }
@@ -0,0 +1,20 @@
1
+ import { Hono } from 'hono'
2
+ import { AppEnv } from '../../types'
3
+ import { listHandler } from './handlers/list'
4
+ import { getByIdHandler, getBySlugHandler } from './handlers/get'
5
+ import { createHandler } from './handlers/create'
6
+ import { updateHandler } from './handlers/update'
7
+ import { deleteHandler } from './handlers/delete'
8
+ import { facetsHandler } from './handlers/facets'
9
+
10
+ const content = new Hono<AppEnv>()
11
+
12
+ content.get('/:slug', listHandler)
13
+ content.get('/:slug/facets', facetsHandler)
14
+ content.get('/:schema_slug/by-slug/:entry_slug', getBySlugHandler)
15
+ content.get('/:slug/:id', getByIdHandler)
16
+ content.post('/:slug', createHandler)
17
+ content.put('/:slug/:id', updateHandler)
18
+ content.delete('/:slug/:id', deleteHandler)
19
+
20
+ export default content