@beechcms/api 0.4.0-preview.11 → 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 (86) 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 -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-9Ch2xWJr.js +0 -554
  86. package/assets/dashboard/assets/index-ye3325L9.css +0 -1
@@ -1,158 +1,158 @@
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}: Modifica`,
146
- message: `L'entry "${slugResult.value.nextSlug}" è stata modificata via API pubblica.`,
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
+ 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
+