@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,217 +1,216 @@
1
- import { 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 } from '../shared/content-utils'
6
- import { checkPublicOperation } from './access-policy'
7
- import { publicProblem } from './problem-details'
8
- import { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
9
- import {
10
- buildPublicFilterWhereClause,
11
- parseLatestCount,
12
- parsePublicFilter,
13
- parsePublicPagination,
14
- } from './query-builder'
15
-
16
- type Bindings = {
17
- DB: D1Database
18
- PUBLIC_READ_API_KEY?: string
19
- PUBLIC_WRITE_API_KEY?: string
20
- PUBLIC_PUBLISHED_ONLY?: string
21
- ENV?: string
22
- }
23
-
24
- type Variables = {
25
- jwtPayload: { sub: string; email?: string }
26
- getSeed: (slug: string) => Seed | null
27
- seedRegistry: Record<string, Seed>
28
- }
29
-
30
- function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
31
- const available = Object.keys(seedRegistry).join(', ')
32
- return `The content type '${seed}' does not exist. Available types: ${available}.`
33
- }
34
-
35
- /** Applica policy public/visibility per la Public API. */
36
- function applyPublicPolicies(aliasData: Record<string, unknown>, seed: Seed): Record<string, unknown> {
37
- const result: Record<string, unknown> = {}
38
- for (const branch of seed.branches) {
39
- const value = aliasData[branch.alias]
40
- const { public: isPublic, visibility } = resolvePolicies(branch)
41
- if (!isPublic) continue
42
- if (visibility === 'hidden') continue
43
- if (visibility === 'masked') {
44
- result[branch.alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
45
- } else {
46
- result[branch.alias] = value
47
- }
48
- }
49
- return result
50
- }
51
-
52
- function toFlatPublicEntry(row: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
53
- const aliasData = applyPublicPolicies(rowToApiData(seed, row), seed)
54
- const base: Record<string, unknown> = {
55
- id: row.id,
56
- slug: row.slug,
57
- status: row.status,
58
- created_at: row.created_at,
59
- updated_at: row.updated_at,
60
- }
61
- const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
62
- if (requestedFields.length === 0) return { ...base, ...aliasData }
63
- const filteredData: Record<string, unknown> = {}
64
- for (const field of requestedFields) {
65
- if (field in aliasData) filteredData[field] = aliasData[field]
66
- }
67
- return { ...base, ...filteredData }
68
- }
69
-
70
- function withCache(
71
- cache: Cache | undefined,
72
- executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined,
73
- cacheKey: Request,
74
- response: Response
75
- ): Response {
76
- if (cache && executionCtx) {
77
- const cloned = response.clone()
78
- const headers = new Headers(cloned.headers)
79
- headers.set('Cache-Control', 'public, max-age=60')
80
- executionCtx.waitUntil(cache.put(cacheKey, new Response(cloned.body, { status: cloned.status, headers })))
81
- }
82
- return response
83
- }
84
-
85
- function buildInternalErrorMessage(c: Context<{ Bindings: Bindings; Variables: Variables }>, err: unknown): string {
86
- if (c.env.ENV !== 'production' && err instanceof Error) return err.message
87
- return 'An unexpected error occurred.'
88
- }
89
-
90
- function buildOrderSql(seed: Seed, query: Record<string, string | undefined>, hasLatest: boolean): string {
91
- if (hasLatest) return 'ORDER BY created_at DESC'
92
- const orderBy = cleanStr(query.orderBy) ?? ''
93
- const orderDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase()
94
- if (orderBy === 'created_at' || orderBy === 'updated_at') {
95
- return `ORDER BY ${orderBy} ${orderDir === 'asc' ? 'ASC' : 'DESC'}`
96
- }
97
- // Branch column always a real column in v0.4.0
98
- const branch = seed.branches.find((b) => b.alias === orderBy)
99
- if (branch) {
100
- const dir = orderDir === 'asc' ? 'ASC' : 'DESC'
101
- return `ORDER BY ${branch.alias} ${dir} NULLS LAST`
102
- }
103
- return 'ORDER BY created_at DESC'
104
- }
105
-
106
- export async function publicReadHandler(c: Context<{ Bindings: Bindings; Variables: Variables }>) {
107
- const seedSlug = c.req.param('seed') ?? ''
108
- const seed = c.get('getSeed')(seedSlug)
109
- if (!seed) {
110
- return publicProblem(c, {
111
- type: 'seed-not-found', title: 'Seed Not Found', status: 404,
112
- detail: buildSeedNotFoundMessage(seedSlug, c.get('seedRegistry')),
113
- })
114
- }
115
- const access = checkPublicOperation(seed, 'read')
116
- if (!access.ok) {
117
- return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
118
- }
119
-
120
- let cache: Cache | undefined
121
- let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
122
- try {
123
- cache = caches.default
124
- executionCtx = c.executionCtx as typeof executionCtx
125
- } catch {}
126
-
127
- const cacheKey = c.req.raw
128
- if (cache) {
129
- const hit = await cache.match(cacheKey)
130
- if (hit) return hit
131
- }
132
-
133
- const query = c.req.query()
134
- const id = cleanStr(query.id)
135
- const table = `content_${seedSlug}`
136
- const publishedOnly = c.env.PUBLIC_PUBLISHED_ONLY !== 'false'
137
-
138
- try {
139
- const { DB } = c.env
140
-
141
- if (id) {
142
- const row = await DB.prepare(
143
- `SELECT * FROM ${table} WHERE id = ? ${publishedOnly ? "AND status = 'published'" : ''} LIMIT 1`
144
- ).bind(id).first<Record<string, unknown>>()
145
-
146
- if (!row) {
147
- return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
148
- }
149
-
150
- return withCache(cache, executionCtx, cacheKey,
151
- c.json({ data: toFlatPublicEntry(row, seed, query.fields), meta: buildPublicSingleMeta(seedSlug) }, 200)
152
- )
153
- }
154
-
155
- const parsedFilter = parsePublicFilter(query.filter)
156
- const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
157
- const latestMode = cleanStr(query.latest) !== null
158
- const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
159
- const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
160
- const offset = (pagination.page - 1) * pagination.limit
161
- const search = cleanStr(query.search) ?? ''
162
-
163
- const whereParts: string[] = []
164
- const whereBindings: Array<string | number> = []
165
- if (publishedOnly) whereParts.push("status = 'published'")
166
-
167
- if (search) {
168
- // Search against slug and text/richtext columns
169
- const searchableBranches = seed.branches.filter((b) => ['text', 'richtext'].includes(b.type))
170
- const term = `%${search}%`
171
- const searchExprs = ['slug LIKE ?', ...searchableBranches.map((b) => `${b.alias} LIKE ?`)]
172
- whereParts.push(`(${searchExprs.join(' OR ')})`)
173
- whereBindings.push(term, ...searchableBranches.map(() => term))
174
- }
175
-
176
- const filterClause = buildPublicFilterWhereClause(seed, parsedFilter)
177
- if (filterClause.clause) {
178
- whereParts.push(`(${filterClause.clause})`)
179
- whereBindings.push(...filterClause.bindings)
180
- }
181
-
182
- const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
183
- const countRow = await DB.prepare(`SELECT COUNT(*) as total FROM ${table} ${whereSql}`)
184
- .bind(...whereBindings)
185
- .first<{ total: number }>()
186
- const total = countRow?.total ?? 0
187
-
188
- const orderSql = buildOrderSql(seed, query, latestMode)
189
- const effectiveLimit = latestMode ? (latestCount ?? 10) : pagination.limit
190
- const effectiveOffset = latestMode ? 0 : offset
191
-
192
- const rowsResult = await DB.prepare(`SELECT * FROM ${table} ${whereSql} ${orderSql} LIMIT ? OFFSET ?`)
193
- .bind(...whereBindings, effectiveLimit, effectiveOffset)
194
- .all<Record<string, unknown>>()
195
-
196
- const rows = rowsResult.results ?? []
197
- const data = rows.map((row) => toFlatPublicEntry(row, seed, query.fields))
198
-
199
- if (latestMode) {
200
- return withCache(cache, executionCtx, cacheKey,
201
- c.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
202
- )
203
- }
204
-
205
- return withCache(cache, executionCtx, cacheKey,
206
- c.json({
207
- data,
208
- meta: buildPublicListMeta({ total, page: pagination.page, limit: effectiveLimit, returned: data.length, seed: seedSlug }),
209
- }, 200)
210
- )
211
- } catch (err) {
212
- if (err instanceof Error && err.message.startsWith('Invalid filter:')) {
213
- return publicProblem(c, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: err.message })
214
- }
215
- return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: buildInternalErrorMessage(c, err) })
216
- }
217
- }
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
+