@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,216 +1,216 @@
1
- import { 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 { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
8
- import {
9
- parseLatestCount,
10
- parsePublicFilter,
11
- parsePublicPagination,
12
- toEngineFilters,
13
- } from './query-builder'
14
- import { AppEnv } from '../types'
15
-
16
- function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
17
- const available = Object.keys(seedRegistry).join(', ')
18
- return `The content type '${seed}' does not exist. Available types: ${available}.`
19
- }
20
-
21
- /** Applica policy public/visibility per la Public API. */
22
- function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
23
- const result: Record<string, unknown> = {}
24
-
25
- // System fields are mapped to top-level for public API
26
- const system = ['id', 'slug', 'status', 'created_at', 'updated_at']
27
- for (const key of system) {
28
- if (key in data) result[key] = data[key]
29
- }
30
-
31
- for (const branch of seed.branches) {
32
- const value = data[branch.alias]
33
- const { public: isPublic, visibility } = resolvePolicies(branch)
34
- if (!isPublic) continue
35
- if (visibility === 'hidden') continue
36
- if (visibility === 'masked') {
37
- result[branch.alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
38
- } else {
39
- result[branch.alias] = value
40
- }
41
- }
42
- return result
43
- }
44
-
45
- function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
46
- const aliasData = applyPublicPolicies(data, seed)
47
- const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
48
-
49
- if (requestedFields.length === 0) return aliasData
50
-
51
- const filteredData: Record<string, unknown> = {}
52
- for (const field of requestedFields) {
53
- if (field in aliasData) filteredData[field] = aliasData[field]
54
- }
55
-
56
- // Always include basic identity fields if they exist in filtered data or if requested
57
- const identity = ['id', 'slug']
58
- for (const key of identity) {
59
- if (key in aliasData && !filteredData[key]) filteredData[key] = aliasData[key]
60
- }
61
-
62
- return filteredData
63
- }
64
-
65
- function withCache(
66
- cache: Cache | undefined,
67
- executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined,
68
- cacheKey: Request,
69
- response: Response
70
- ): Response {
71
- if (cache && executionCtx) {
72
- const cloned = response.clone()
73
- const headers = new Headers(cloned.headers)
74
- headers.set('Cache-Control', 'public, max-age=60')
75
- executionCtx.waitUntil(cache.put(cacheKey, new Response(cloned.body, { status: cloned.status, headers })))
76
- }
77
- return response
78
- }
79
-
80
- function buildInternalErrorMessage(context: Context<AppEnv>, error: unknown): string {
81
- if (context.env.ENV !== 'production' && error instanceof Error) return error.message
82
- return 'An unexpected error occurred.'
83
- }
84
-
85
- export async function publicReadHandler(context: Context<AppEnv>) {
86
- const seedSlug = context.req.param('seed') ?? ''
87
- const seed = context.get('getSeed')(seedSlug)
88
- if (!seed) {
89
- return publicProblem(context, {
90
- type: 'seed-not-found',
91
- title: 'Seed Not Found',
92
- status: 404,
93
- detail: buildSeedNotFoundMessage(seedSlug, context.get('seedRegistry')),
94
- })
95
- }
96
-
97
- const access = checkPublicOperation(seed, 'read')
98
- if (!access.ok) {
99
- return publicProblem(context, {
100
- type: 'operation-not-allowed',
101
- title: access.error.error,
102
- status: 403,
103
- detail: access.error.message
104
- })
105
- }
106
-
107
- let cache: Cache | undefined
108
- let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
109
- try {
110
- cache = caches.default
111
- executionCtx = context.executionCtx as typeof executionCtx
112
- } catch {}
113
-
114
- const cacheKey = context.req.raw
115
- if (cache) {
116
- const hit = await cache.match(cacheKey)
117
- if (hit) return hit
118
- }
119
-
120
- const query = context.req.query()
121
- const id = cleanStr(query.id)
122
- const slug = cleanStr(query.slug)
123
- const publishedOnly = context.env.PUBLIC_PUBLISHED_ONLY !== 'false'
124
- const repository = context.get('repository')
125
-
126
- try {
127
- if (id || slug) {
128
- try {
129
- const entry = id
130
- ? await repository.findById(seed, id)
131
- : await repository.findBySlug(seed, slug!)
132
-
133
- if (publishedOnly && entry.status !== 'published') {
134
- return publicProblem(context, {
135
- type: 'entry-not-found',
136
- title: 'Not Found',
137
- status: 404,
138
- detail: `Entry '${id || slug}' not found or not published.`
139
- })
140
- }
141
-
142
- return withCache(cache, executionCtx, cacheKey,
143
- context.json({
144
- data: toFlatPublicEntry(entry, seed, query.fields),
145
- meta: buildPublicSingleMeta(seedSlug)
146
- }, 200)
147
- )
148
- } catch (error) {
149
- if (error instanceof EntryNotFoundError) {
150
- return publicProblem(context, {
151
- type: 'entry-not-found',
152
- title: 'Not Found',
153
- status: 404,
154
- detail: `Entry '${id || slug}' not found for content type '${seedSlug}'.`
155
- })
156
- }
157
- throw error
158
- }
159
- }
160
-
161
- const parsedFilter = parsePublicFilter(query.filter)
162
- const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
163
- const latestMode = cleanStr(query.latest) !== null
164
- const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
165
- const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
166
- const offset = (pagination.page - 1) * pagination.limit
167
- const search = cleanStr(query.search) ?? ''
168
-
169
- const engineFilters = toEngineFilters(seed, parsedFilter)
170
- const sortBy = cleanStr(query.orderBy) ?? 'created_at'
171
- const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
172
-
173
- const { items, total } = await repository.findMany(seed, {
174
- filters: engineFilters,
175
- search: search || undefined,
176
- status: publishedOnly ? 'published' : null,
177
- pagination: {
178
- limit: latestMode ? (latestCount ?? 10) : pagination.limit,
179
- offset: latestMode ? 0 : offset
180
- },
181
- orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir }
182
- })
183
-
184
- const data = items.map((item) => toFlatPublicEntry(item, seed, query.fields))
185
-
186
- if (latestMode) {
187
- return withCache(cache, executionCtx, cacheKey,
188
- context.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
189
- )
190
- }
191
-
192
- return withCache(cache, executionCtx, cacheKey,
193
- context.json({
194
- data,
195
- meta: buildPublicListMeta({
196
- total,
197
- page: pagination.page,
198
- limit: latestMode ? (latestCount ?? 10) : pagination.limit,
199
- returned: data.length,
200
- seed: seedSlug
201
- }),
202
- }, 200)
203
- )
204
- } catch (error) {
205
- if (error instanceof Error && error.message.startsWith('Invalid filter:')) {
206
- return publicProblem(context, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: error.message })
207
- }
208
- console.error('Public read error:', error)
209
- return publicProblem(context, {
210
- type: 'internal-server-error',
211
- title: 'Internal Server Error',
212
- status: 500,
213
- detail: buildInternalErrorMessage(context, error)
214
- })
215
- }
216
- }
1
+ import { 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 { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
8
+ import {
9
+ parseLatestCount,
10
+ parsePublicFilter,
11
+ parsePublicPagination,
12
+ toEngineFilters,
13
+ } from './query-builder'
14
+ import { AppEnv } from '../types'
15
+
16
+ function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
17
+ const available = Object.keys(seedRegistry).join(', ')
18
+ return `The content type '${seed}' does not exist. Available types: ${available}.`
19
+ }
20
+
21
+ /** Applies public/visibility policies for the Public API. */
22
+ function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
23
+ const result: Record<string, unknown> = {}
24
+
25
+ // System fields are mapped to top-level for public API
26
+ const system = ['id', 'slug', 'status', 'created_at', 'updated_at']
27
+ for (const key of system) {
28
+ if (key in data) result[key] = data[key]
29
+ }
30
+
31
+ for (const branch of seed.branches) {
32
+ const value = data[branch.alias]
33
+ const { public: isPublic, visibility } = resolvePolicies(branch)
34
+ if (!isPublic) continue
35
+ if (visibility === 'hidden') continue
36
+ if (visibility === 'masked') {
37
+ result[branch.alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
38
+ } else {
39
+ result[branch.alias] = value
40
+ }
41
+ }
42
+ return result
43
+ }
44
+
45
+ function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
46
+ const aliasData = applyPublicPolicies(data, seed)
47
+ const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
48
+
49
+ if (requestedFields.length === 0) return aliasData
50
+
51
+ const filteredData: Record<string, unknown> = {}
52
+ for (const field of requestedFields) {
53
+ if (field in aliasData) filteredData[field] = aliasData[field]
54
+ }
55
+
56
+ // Always include basic identity fields if they exist in filtered data or if requested
57
+ const identity = ['id', 'slug']
58
+ for (const key of identity) {
59
+ if (key in aliasData && !filteredData[key]) filteredData[key] = aliasData[key]
60
+ }
61
+
62
+ return filteredData
63
+ }
64
+
65
+ function withCache(
66
+ cache: Cache | undefined,
67
+ executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined,
68
+ cacheKey: Request,
69
+ response: Response
70
+ ): Response {
71
+ if (cache && executionCtx) {
72
+ const cloned = response.clone()
73
+ const headers = new Headers(cloned.headers)
74
+ headers.set('Cache-Control', 'public, max-age=60')
75
+ executionCtx.waitUntil(cache.put(cacheKey, new Response(cloned.body, { status: cloned.status, headers })))
76
+ }
77
+ return response
78
+ }
79
+
80
+ function buildInternalErrorMessage(context: Context<AppEnv>, error: unknown): string {
81
+ if (context.env.ENV !== 'production' && error instanceof Error) return error.message
82
+ return 'An unexpected error occurred.'
83
+ }
84
+
85
+ export async function publicReadHandler(context: Context<AppEnv>) {
86
+ const seedSlug = context.req.param('seed') ?? ''
87
+ const seed = context.get('getSeed')(seedSlug)
88
+ if (!seed) {
89
+ return publicProblem(context, {
90
+ type: 'seed-not-found',
91
+ title: 'Seed Not Found',
92
+ status: 404,
93
+ detail: buildSeedNotFoundMessage(seedSlug, context.get('seedRegistry')),
94
+ })
95
+ }
96
+
97
+ const access = checkPublicOperation(seed, 'read')
98
+ if (!access.ok) {
99
+ return publicProblem(context, {
100
+ type: 'operation-not-allowed',
101
+ title: access.error.error,
102
+ status: 403,
103
+ detail: access.error.message
104
+ })
105
+ }
106
+
107
+ let cache: Cache | undefined
108
+ let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
109
+ try {
110
+ cache = caches.default
111
+ executionCtx = context.executionCtx as typeof executionCtx
112
+ } catch {}
113
+
114
+ const cacheKey = context.req.raw
115
+ if (cache) {
116
+ const hit = await cache.match(cacheKey)
117
+ if (hit) return hit
118
+ }
119
+
120
+ const query = context.req.query()
121
+ const id = cleanStr(query.id)
122
+ const slug = cleanStr(query.slug)
123
+ const publishedOnly = context.env.PUBLIC_PUBLISHED_ONLY !== 'false'
124
+ const repository = context.get('repository')
125
+
126
+ try {
127
+ if (id || slug) {
128
+ try {
129
+ const entry = id
130
+ ? await repository.findById(seed, id)
131
+ : await repository.findBySlug(seed, slug!)
132
+
133
+ if (publishedOnly && entry.status !== 'published') {
134
+ return publicProblem(context, {
135
+ type: 'entry-not-found',
136
+ title: 'Not Found',
137
+ status: 404,
138
+ detail: `Entry '${id || slug}' not found or not published.`
139
+ })
140
+ }
141
+
142
+ return withCache(cache, executionCtx, cacheKey,
143
+ context.json({
144
+ data: toFlatPublicEntry(entry, seed, query.fields),
145
+ meta: buildPublicSingleMeta(seedSlug)
146
+ }, 200)
147
+ )
148
+ } catch (error) {
149
+ if (error instanceof EntryNotFoundError) {
150
+ return publicProblem(context, {
151
+ type: 'entry-not-found',
152
+ title: 'Not Found',
153
+ status: 404,
154
+ detail: `Entry '${id || slug}' not found for content type '${seedSlug}'.`
155
+ })
156
+ }
157
+ throw error
158
+ }
159
+ }
160
+
161
+ const parsedFilter = parsePublicFilter(query.filter)
162
+ const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
163
+ const latestMode = cleanStr(query.latest) !== null
164
+ const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
165
+ const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
166
+ const offset = (pagination.page - 1) * pagination.limit
167
+ const search = cleanStr(query.search) ?? ''
168
+
169
+ const engineFilters = toEngineFilters(seed, parsedFilter)
170
+ const sortBy = cleanStr(query.orderBy) ?? 'created_at'
171
+ const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
172
+
173
+ const { items, total } = await repository.findMany(seed, {
174
+ filters: engineFilters,
175
+ search: search || undefined,
176
+ status: publishedOnly ? 'published' : null,
177
+ pagination: {
178
+ limit: latestMode ? (latestCount ?? 10) : pagination.limit,
179
+ offset: latestMode ? 0 : offset
180
+ },
181
+ orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir }
182
+ })
183
+
184
+ const data = items.map((item) => toFlatPublicEntry(item, seed, query.fields))
185
+
186
+ if (latestMode) {
187
+ return withCache(cache, executionCtx, cacheKey,
188
+ context.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
189
+ )
190
+ }
191
+
192
+ return withCache(cache, executionCtx, cacheKey,
193
+ context.json({
194
+ data,
195
+ meta: buildPublicListMeta({
196
+ total,
197
+ page: pagination.page,
198
+ limit: latestMode ? (latestCount ?? 10) : pagination.limit,
199
+ returned: data.length,
200
+ seed: seedSlug
201
+ }),
202
+ }, 200)
203
+ )
204
+ } catch (error) {
205
+ if (error instanceof Error && error.message.startsWith('Invalid filter:')) {
206
+ return publicProblem(context, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: error.message })
207
+ }
208
+ console.error('Public read error:', error)
209
+ return publicProblem(context, {
210
+ type: 'internal-server-error',
211
+ title: 'Internal Server Error',
212
+ status: 500,
213
+ detail: buildInternalErrorMessage(context, error)
214
+ })
215
+ }
216
+ }
@@ -1,31 +1,84 @@
1
- import { Hono } from 'hono'
2
- import { publicReadHandler } from './public-read'
3
- import { publicAddHandler } from './public-add'
4
- import { publicEditHandler } from './public-edit'
5
-
6
- type Bindings = {
7
- DB: D1Database
8
- PUBLIC_READ_API_KEY?: string
9
- PUBLIC_WRITE_API_KEY?: string
10
- ENV?: string
11
- }
12
-
13
- type Variables = {
14
- jwtPayload: { sub: string; email?: string }
15
- }
16
-
17
- const publicApp = new Hono<{ Bindings: Bindings; Variables: Variables }>()
18
-
19
- /**
20
- * Step 1 smoke endpoint to validate API key middleware flow.
21
- */
22
- publicApp.get('/health', (c) => {
23
- return c.json({ ok: true, service: 'public-api' }, 200)
24
- })
25
-
26
- publicApp.get('/:seed', publicReadHandler)
27
- publicApp.post('/:seed/add', publicAddHandler)
28
- publicApp.put('/:seed/edit/:id', publicEditHandler)
29
-
30
- export const publicRoutes = publicApp
31
-
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types'
3
+ import { publicReadHandler } from './public-read'
4
+ import { publicAddHandler } from './public-add'
5
+ import { publicEditHandler } from './public-edit'
6
+
7
+ const publicApp = new Hono<AppEnv>()
8
+
9
+ publicApp.get('/health', (c) => {
10
+ return c.json({ ok: true, service: 'public-api' }, 200)
11
+ })
12
+
13
+ /** Returns the public-facing schema: only seeds with public read or post enabled. */
14
+ publicApp.get('/schema', (c) => {
15
+ const registry = c.get('seedRegistry')
16
+ const publicSeeds = Object.values(registry)
17
+ .filter(seed => seed.allowPublicRead === true || seed.allowPublicPost === true || seed.allowPublicEdit === true)
18
+ .map(seed => ({
19
+ slug: seed.slug,
20
+ label: seed.label,
21
+ labelPlural: seed.labelPlural,
22
+ allowPublicRead: seed.allowPublicRead ?? false,
23
+ allowPublicPost: seed.allowPublicPost ?? false,
24
+ allowPublicEdit: seed.allowPublicEdit ?? false,
25
+ branches: (seed.branches ?? []).map(branch => ({
26
+ alias: branch.alias,
27
+ type: branch.type,
28
+ label: branch.label,
29
+ requiredOnCreate: branch.requiredOnCreate ?? false,
30
+ policies: {
31
+ public: branch.policies?.public ?? true,
32
+ visibility: branch.policies?.visibility ?? 'full',
33
+ },
34
+ })),
35
+ }))
36
+ return c.json({ seeds: publicSeeds })
37
+ })
38
+
39
+ /** HTML render of the public schema for quick browser inspection. */
40
+ publicApp.get('/schema.html', (c) => {
41
+ const registry = c.get('seedRegistry')
42
+ const publicSeeds = Object.values(registry)
43
+ .filter(seed => seed.allowPublicRead === true || seed.allowPublicPost === true || seed.allowPublicEdit === true)
44
+
45
+ const seedHtml = publicSeeds.map(seed => {
46
+ const ops = [
47
+ seed.allowPublicRead && 'GET /:seed',
48
+ seed.allowPublicPost && 'POST /:seed/add',
49
+ seed.allowPublicEdit && 'PUT /:seed/edit/:id',
50
+ ].filter(Boolean).join(', ')
51
+
52
+ const branchRows = (seed.branches ?? []).map(b =>
53
+ `<tr><td>${b.alias}</td><td>${b.type}</td><td>${b.label}</td><td>${b.requiredOnCreate ? 'yes' : ''}</td><td>${(b.policies?.public ?? true) ? 'readable' : 'hidden'}</td></tr>`
54
+ ).join('')
55
+
56
+ return `<section>
57
+ <h2>${seed.label} <code>${seed.slug}</code></h2>
58
+ <p><strong>Allowed operations:</strong> ${ops || 'none'}</p>
59
+ <table><thead><tr><th>alias</th><th>type</th><th>label</th><th>required</th><th>public access</th></tr></thead>
60
+ <tbody>${branchRows}</tbody></table>
61
+ </section>`
62
+ }).join('\n')
63
+
64
+ const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
65
+ <title>BeechCMS Public Schema</title>
66
+ <style>body{font-family:system-ui,sans-serif;max-width:900px;margin:2rem auto;padding:0 1rem;color:#222}
67
+ code{background:#f3f4f6;padding:2px 6px;border-radius:4px}
68
+ table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:6px 10px;border:1px solid #e5e7eb}
69
+ th{background:#f9fafb}h2{margin-top:2rem}section{margin-bottom:2rem}</style>
70
+ </head><body>
71
+ <h1>BeechCMS — Public API Schema</h1>
72
+ <p>Only content types with public access enabled are listed here.</p>
73
+ ${seedHtml || '<p><em>No content types with public access configured.</em></p>'}
74
+ </body></html>`
75
+
76
+ return c.html(html)
77
+ })
78
+
79
+ publicApp.get('/:seed', publicReadHandler)
80
+ publicApp.post('/:seed/add', publicAddHandler)
81
+ publicApp.put('/:seed/edit/:id', publicEditHandler)
82
+
83
+ export const publicRoutes = publicApp
84
+