@beechcms/api 0.4.0-preview.12 → 0.4.0-preview.13

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