@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
@@ -1,183 +1,159 @@
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 { AppEnv } from '../types'
10
+
11
+ 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
12
+ type PublicCtx = Context<AppEnv>
13
+ type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
14
+
15
+ function errorMessage(context: PublicCtx, error: unknown): string {
16
+ if (context.env.ENV !== 'production' && error instanceof Error) return error.message
17
+ return 'An unexpected error occurred.'
18
+ }
19
+
20
+ function asRecord(value: unknown): Record<string, unknown> | null {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
22
+ ? (value as Record<string, unknown>)
23
+ : null
24
+ }
25
+
26
+ function removeNullishFields(data: Record<string, unknown>): Record<string, unknown> {
27
+ const next: Record<string, unknown> = {}
28
+ for (const [key, value] of Object.entries(data)) {
29
+ if (value !== null) next[key] = value
30
+ }
31
+ return next
32
+ }
33
+
34
+ function parseBody(context: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
35
+ return context.req.json<unknown>()
36
+ .then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
37
+ .catch(() => ({
38
+ ok: false,
39
+ response: publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
40
+ }))
41
+ }
42
+
43
+ function resolveSlug(context: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
44
+ const slugRequested = Object.hasOwn(body, 'slug')
45
+ if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
46
+ const requestedSlug = cleanStr(body.slug)
47
+ if (!requestedSlug) {
48
+ return { ok: false, response: publicProblem(context, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
49
+ }
50
+ return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
51
+ }
52
+
53
+ function resolveStatus(context: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
54
+ if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
55
+ const statusValue = body.status
56
+ if (!isValidContentStatus(statusValue)) {
57
+ return { ok: false, response: publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
58
+ }
59
+ return { ok: true, value: statusValue as string }
60
+ }
61
+
62
+ function resolveData(
63
+ context: PublicCtx,
64
+ seed: Seed,
65
+ body: Record<string, unknown>
66
+ ): ResolveResult<Record<string, unknown>> {
67
+ if (!Object.hasOwn(body, 'data')) {
68
+ // No data update return empty patch
69
+ return { ok: true, value: {} }
70
+ }
71
+
72
+ const rawData = asRecord(body.data)
73
+ if (!rawData) {
74
+ return { ok: false, response: publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
75
+ }
76
+
77
+ const sensitiveAliases = Object.keys(rawData).filter((alias) => {
78
+ const branch = seed.branches.find((b) => b.alias === alias)
79
+ if (!branch) return false
80
+ const policies = resolvePolicies(branch)
81
+ return policies.privacy !== 'plain' || policies.public === false
82
+ })
83
+ if (sensitiveAliases.length > 0) {
84
+ return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
85
+ }
86
+
87
+ const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
88
+ if (!sanitized.ok) {
89
+ if (sanitized.status === 422) {
90
+ return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
91
+ }
92
+ return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
93
+ }
94
+
95
+ return { ok: true, value: removeNullishFields(sanitized.data) }
96
+ }
97
+
98
+ export async function publicEditHandler(context: PublicCtx) {
99
+ const seedSlug = context.req.param('seed') ?? ''
100
+ const id = context.req.param('id') ?? ''
101
+ const seed = context.get('getSeed')(seedSlug)
102
+ if (!seed) {
103
+ return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
104
+ }
105
+ const access = checkPublicOperation(seed, 'edit')
106
+ if (!access.ok) {
107
+ return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
108
+ }
109
+
110
+ if (!UUID_REGEX.test(id)) {
111
+ return publicProblem(context, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
112
+ }
113
+
114
+ const repository = context.get('repository')
115
+
116
+ try {
117
+ const entry = await repository.findById(seed, id)
118
+
119
+ const bodyResult = await parseBody(context)
120
+ if (!bodyResult.ok) return bodyResult.response
121
+
122
+ const slugResult = resolveSlug(context, bodyResult.value, (entry.slug as string) ?? '')
123
+ if (!slugResult.ok) return slugResult.response
124
+
125
+ const statusResult = resolveStatus(context, bodyResult.value, (entry.status as string) ?? 'draft')
126
+ if (!statusResult.ok) return statusResult.response
127
+
128
+ const dataResult = resolveData(context, seed, bodyResult.value)
129
+ if (!dataResult.ok) return dataResult.response
130
+
131
+ if (slugResult.value.slugRequested && slugResult.value.nextSlug !== entry.slug) {
132
+ const exists = await repository.existsSlug(seed, slugResult.value.nextSlug, id)
133
+ if (exists) {
134
+ return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
135
+ }
136
+ }
137
+
138
+ const updateData = { ...dataResult.value }
139
+ if (slugResult.value.slugRequested) {
140
+ (updateData as any).slug = slugResult.value.nextSlug
141
+ }
142
+
143
+ await repository.update(seed, id, updateData, statusResult.value)
144
+
145
+ context.get('notificationService').notify({
146
+ title: `${seed.label}: Update`,
147
+ message: `The entry "${slugResult.value.nextSlug}" has been modified via the public API.`,
148
+ type: 'info',
149
+ })
150
+
151
+ return context.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
152
+ } catch (error) {
153
+ if (error instanceof EntryNotFoundError) {
154
+ return publicProblem(context, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
155
+ }
156
+ console.error('Public edit error:', error)
157
+ return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(context, error) })
158
+ }
159
+ }
@@ -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
+