@beechcms/api 0.4.0-preview.8 → 0.4.0

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 (89) 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-FQ6JhvRH.js +554 -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/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 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  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 -249
  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 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  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 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  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 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  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 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
@@ -1,183 +1,158 @@
1
- import { isValidContentStatus, resolvePolicies } from '@beechcms/core'
2
- import type { Seed } from '@beechcms/core'
3
- import type { Context } from 'hono'
4
- import { cleanStr } from '../shared/query-utils'
5
- import { rowToApiData, buildUpdateBindings } from '../shared/content-utils'
6
- import { checkPublicOperation } from './access-policy'
7
- import { publicProblem } from './problem-details'
8
- import { slugify } from './slug-utils'
9
- import { sanitizePublicPayload } from './sanitize'
10
- import { createNotification } from '../shared/notification-service'
11
-
12
- type Bindings = {
13
- DB: D1Database
14
- PUBLIC_READ_API_KEY?: string
15
- PUBLIC_WRITE_API_KEY?: string
16
- ENV?: string
17
- }
18
-
19
- type Variables = {
20
- jwtPayload: { sub: string; email?: string }
21
- getSeed: (slug: string) => Seed | null
22
- seedRegistry: Record<string, Seed>
23
- }
24
-
25
- const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
26
- type PublicCtx = Context<{ Bindings: Bindings; Variables: Variables }>
27
- type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
28
-
29
- function errorMessage(c: PublicCtx, err: unknown): string {
30
- if (c.env.ENV !== 'production' && err instanceof Error) return err.message
31
- return 'An unexpected error occurred.'
32
- }
33
-
34
- function asRecord(value: unknown): Record<string, unknown> | null {
35
- return value !== null && typeof value === 'object' && !Array.isArray(value)
36
- ? (value as Record<string, unknown>)
37
- : null
38
- }
39
-
40
- function removeNullishFields(data: Record<string, unknown>): Record<string, unknown> {
41
- const next: Record<string, unknown> = {}
42
- for (const [key, value] of Object.entries(data)) {
43
- if (value !== null) next[key] = value
44
- }
45
- return next
46
- }
47
-
48
- function parseBody(c: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
49
- return c.req.json<unknown>()
50
- .then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
51
- .catch(() => ({
52
- ok: false,
53
- response: publicProblem(c, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
54
- }))
55
- }
56
-
57
- function resolveSlug(c: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
58
- const slugRequested = Object.hasOwn(body, 'slug')
59
- if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
60
- const requestedSlug = cleanStr(body.slug)
61
- if (!requestedSlug) {
62
- return { ok: false, response: publicProblem(c, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
63
- }
64
- return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
65
- }
66
-
67
- function resolveStatus(c: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
68
- if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
69
- const statusValue = body.status
70
- if (!isValidContentStatus(statusValue)) {
71
- return { ok: false, response: publicProblem(c, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
72
- }
73
- return { ok: true, value: statusValue }
74
- }
75
-
76
- function resolveData(
77
- c: PublicCtx,
78
- seed: Seed,
79
- body: Record<string, unknown>,
80
- currentRow: Record<string, unknown>
81
- ): ResolveResult<Record<string, unknown>> {
82
- if (!Object.hasOwn(body, 'data')) {
83
- // No data update return empty patch (caller keeps existing columns)
84
- return { ok: true, value: {} }
85
- }
86
-
87
- const rawData = asRecord(body.data)
88
- if (!rawData) {
89
- return { ok: false, response: publicProblem(c, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
90
- }
91
-
92
- const sensitiveAliases = Object.keys(rawData).filter((alias) => {
93
- const branch = seed.branches.find((b) => b.alias === alias)
94
- return branch != null && resolvePolicies(branch).privacy !== 'plain'
95
- })
96
- if (sensitiveAliases.length > 0) {
97
- return { ok: false, response: publicProblem(c, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
98
- }
99
-
100
- const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
101
- if (!sanitized.ok) {
102
- if (sanitized.status === 422) {
103
- return { ok: false, response: publicProblem(c, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
104
- }
105
- return { ok: false, response: publicProblem(c, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
106
- }
107
-
108
- // Merge: current columns + patch, remove nulls
109
- const currentAliasData = rowToApiData(seed, currentRow)
110
- const merged = removeNullishFields({ ...currentAliasData, ...sanitized.data })
111
- return { ok: true, value: merged }
112
- }
113
-
114
- export async function publicEditHandler(c: PublicCtx) {
115
- const seedSlug = c.req.param('seed') ?? ''
116
- const id = c.req.param('id') ?? ''
117
- const seed = c.get('getSeed')(seedSlug)
118
- if (!seed) {
119
- return publicProblem(c, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
120
- }
121
- const access = checkPublicOperation(seed, 'edit')
122
- if (!access.ok) {
123
- return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
124
- }
125
-
126
- if (!UUID_REGEX.test(id)) {
127
- return publicProblem(c, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
128
- }
129
-
130
- const table = `content_${seedSlug}`
131
-
132
- try {
133
- const { DB } = c.env
134
- const currentRow = await DB.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
135
- .bind(id)
136
- .first<Record<string, unknown>>()
137
-
138
- if (!currentRow) {
139
- return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
140
- }
141
-
142
- const bodyResult = await parseBody(c)
143
- if (!bodyResult.ok) return bodyResult.response
144
-
145
- const slugResult = resolveSlug(c, bodyResult.value, (currentRow.slug as string) ?? '')
146
- if (!slugResult.ok) return slugResult.response
147
-
148
- const statusResult = resolveStatus(c, bodyResult.value, (currentRow.status as string) ?? 'draft')
149
- if (!statusResult.ok) return statusResult.response
150
-
151
- const dataResult = resolveData(c, seed, bodyResult.value, currentRow)
152
- if (!dataResult.ok) return dataResult.response
153
-
154
- if (slugResult.value.slugRequested) {
155
- const slugExisting = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ? AND id != ? LIMIT 1`)
156
- .bind(slugResult.value.nextSlug, id)
157
- .first<{ id: string }>()
158
-
159
- if (slugExisting) {
160
- return publicProblem(c, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
161
- }
162
- }
163
-
164
- const now = Math.floor(Date.now() / 1000)
165
- const { setClause, bindings } = buildUpdateBindings(seed, dataResult.value)
166
- const systemSet = `slug = ?, status = ?, updated_at = ?`
167
- const fullSet = setClause ? `${systemSet}, ${setClause}` : systemSet
168
-
169
- await DB.prepare(`UPDATE ${table} SET ${fullSet} WHERE id = ?`)
170
- .bind(slugResult.value.nextSlug, statusResult.value, now, ...bindings, id)
171
- .run()
172
-
173
- await createNotification(c, {
174
- title: `${seed.label}: Modifica`,
175
- message: `L'entry "${slugResult.value.nextSlug}" è stata modificata via API pubblica.`,
176
- type: 'info',
177
- })
178
-
179
- return c.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
180
- } catch (err) {
181
- return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(c, err) })
182
- }
183
- }
1
+ import { isValidContentStatus, resolvePolicies, EntryNotFoundError } from '@beechcms/core'
2
+ import type { Seed } from '@beechcms/core'
3
+ import type { Context } from 'hono'
4
+ import { cleanStr } from '../shared/query-utils'
5
+ import { checkPublicOperation } from './access-policy'
6
+ import { publicProblem } from './problem-details'
7
+ import { slugify } from './slug-utils'
8
+ import { sanitizePublicPayload } from './sanitize'
9
+ import { createNotification } from '../shared/notification-service'
10
+ import { AppEnv } from '../types'
11
+
12
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
13
+ type PublicCtx = Context<AppEnv>
14
+ type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
15
+
16
+ function errorMessage(context: PublicCtx, error: unknown): string {
17
+ if (context.env.ENV !== 'production' && error instanceof Error) return error.message
18
+ return 'An unexpected error occurred.'
19
+ }
20
+
21
+ function asRecord(value: unknown): Record<string, unknown> | null {
22
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
23
+ ? (value as Record<string, unknown>)
24
+ : null
25
+ }
26
+
27
+ function removeNullishFields(data: Record<string, unknown>): Record<string, unknown> {
28
+ const next: Record<string, unknown> = {}
29
+ for (const [key, value] of Object.entries(data)) {
30
+ if (value !== null) next[key] = value
31
+ }
32
+ return next
33
+ }
34
+
35
+ function parseBody(context: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
36
+ return context.req.json<unknown>()
37
+ .then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
38
+ .catch(() => ({
39
+ ok: false,
40
+ response: publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
41
+ }))
42
+ }
43
+
44
+ function resolveSlug(context: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
45
+ const slugRequested = Object.hasOwn(body, 'slug')
46
+ if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
47
+ const requestedSlug = cleanStr(body.slug)
48
+ if (!requestedSlug) {
49
+ return { ok: false, response: publicProblem(context, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
50
+ }
51
+ return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
52
+ }
53
+
54
+ function resolveStatus(context: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
55
+ if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
56
+ const statusValue = body.status
57
+ if (!isValidContentStatus(statusValue)) {
58
+ return { ok: false, response: publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
59
+ }
60
+ return { ok: true, value: statusValue as string }
61
+ }
62
+
63
+ function resolveData(
64
+ context: PublicCtx,
65
+ seed: Seed,
66
+ body: Record<string, unknown>
67
+ ): ResolveResult<Record<string, unknown>> {
68
+ if (!Object.hasOwn(body, 'data')) {
69
+ // No data update — return empty patch
70
+ return { ok: true, value: {} }
71
+ }
72
+
73
+ const rawData = asRecord(body.data)
74
+ if (!rawData) {
75
+ return { ok: false, response: publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
76
+ }
77
+
78
+ const sensitiveAliases = Object.keys(rawData).filter((alias) => {
79
+ const branch = seed.branches.find((b) => b.alias === alias)
80
+ return branch != null && resolvePolicies(branch).privacy !== 'plain'
81
+ })
82
+ if (sensitiveAliases.length > 0) {
83
+ return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
84
+ }
85
+
86
+ const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
87
+ if (!sanitized.ok) {
88
+ if (sanitized.status === 422) {
89
+ return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
90
+ }
91
+ return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
92
+ }
93
+
94
+ return { ok: true, value: removeNullishFields(sanitized.data) }
95
+ }
96
+
97
+ export async function publicEditHandler(context: PublicCtx) {
98
+ const seedSlug = context.req.param('seed') ?? ''
99
+ const id = context.req.param('id') ?? ''
100
+ const seed = context.get('getSeed')(seedSlug)
101
+ if (!seed) {
102
+ return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
103
+ }
104
+ const access = checkPublicOperation(seed, 'edit')
105
+ if (!access.ok) {
106
+ return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
107
+ }
108
+
109
+ if (!UUID_REGEX.test(id)) {
110
+ return publicProblem(context, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
111
+ }
112
+
113
+ const repository = context.get('repository')
114
+
115
+ try {
116
+ const entry = await repository.findById(seed, id)
117
+
118
+ const bodyResult = await parseBody(context)
119
+ if (!bodyResult.ok) return bodyResult.response
120
+
121
+ const slugResult = resolveSlug(context, bodyResult.value, (entry.slug as string) ?? '')
122
+ if (!slugResult.ok) return slugResult.response
123
+
124
+ const statusResult = resolveStatus(context, bodyResult.value, (entry.status as string) ?? 'draft')
125
+ if (!statusResult.ok) return statusResult.response
126
+
127
+ const dataResult = resolveData(context, seed, bodyResult.value)
128
+ if (!dataResult.ok) return dataResult.response
129
+
130
+ if (slugResult.value.slugRequested && slugResult.value.nextSlug !== entry.slug) {
131
+ const exists = await repository.existsSlug(seed, slugResult.value.nextSlug, id)
132
+ if (exists) {
133
+ return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
134
+ }
135
+ }
136
+
137
+ const updateData = { ...dataResult.value }
138
+ if (slugResult.value.slugRequested) {
139
+ (updateData as any).slug = slugResult.value.nextSlug
140
+ }
141
+
142
+ await repository.update(seed, id, updateData, statusResult.value)
143
+
144
+ await createNotification(context, {
145
+ title: `${seed.label}: Update`,
146
+ message: `The entry "${slugResult.value.nextSlug}" has been modified via the public API.`,
147
+ type: 'info',
148
+ })
149
+
150
+ return context.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
151
+ } catch (error) {
152
+ if (error instanceof EntryNotFoundError) {
153
+ return publicProblem(context, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
154
+ }
155
+ console.error('Public edit error:', error)
156
+ return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(context, error) })
157
+ }
158
+ }
@@ -1,15 +1,15 @@
1
- /**
2
- * Error constants for Public API responses.
3
- */
4
- export const PUBLIC_ERRORS = {
5
- API_KEY_UNAUTHORIZED: {
6
- error: 'Unauthorized',
7
- message:
8
- 'Missing or invalid API key. Provide a valid key via X-API-Key header.',
9
- },
10
- API_KEY_FORBIDDEN: {
11
- error: 'Forbidden',
12
- message: 'Public API access is not configured for this instance.',
13
- },
14
- } as const
15
-
1
+ /**
2
+ * Error constants for Public API responses.
3
+ */
4
+ export const PUBLIC_ERRORS = {
5
+ API_KEY_UNAUTHORIZED: {
6
+ error: 'Unauthorized',
7
+ message:
8
+ 'Missing or invalid API key. Provide a valid key via X-API-Key header.',
9
+ },
10
+ API_KEY_FORBIDDEN: {
11
+ error: 'Forbidden',
12
+ message: 'Public API access is not configured for this instance.',
13
+ },
14
+ } as const
15
+