@beechcms/api 0.4.0 → 0.4.2

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 (138) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/auth/bcrypt-hash-provider.ts +20 -0
  8. package/src/auth/constants.ts +3 -3
  9. package/src/auth/generate-refresh-token.test.ts +19 -0
  10. package/src/auth/hash-provider.test.ts +46 -0
  11. package/src/auth/in-memory-hash-provider.ts +13 -0
  12. package/src/auth/jose-token-service.ts +55 -0
  13. package/src/auth/login.test.ts +92 -0
  14. package/src/auth/login.ts +15 -32
  15. package/src/auth/refresh.ts +0 -122
  16. package/src/auth/static-token-service.ts +18 -0
  17. package/src/auth/token-service.test.ts +82 -0
  18. package/src/factory.ts +80 -80
  19. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  20. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  21. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  22. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  23. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  24. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  25. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  26. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  27. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  28. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  29. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  30. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  31. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  32. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  33. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  34. package/src/features/automations/action-executors/index.ts +33 -0
  35. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  36. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  37. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  38. package/src/features/automations/automation-runner.ts +81 -0
  39. package/src/features/automations/automation-runner.utils.ts +43 -0
  40. package/src/features/automations/automations.handler.ts +193 -0
  41. package/src/features/automations/automations.schema.ts +160 -0
  42. package/src/features/automations/context-resolver.ts +148 -0
  43. package/src/features/automations/cron-runner.ts +136 -0
  44. package/src/features/automations/cron-runner.utils.ts +40 -0
  45. package/src/features/automations/filter-translation.ts +42 -0
  46. package/src/features/automations/index.ts +12 -0
  47. package/src/features/automations/template-grammar.ts +241 -0
  48. package/src/features/automations/var-access-resolver.ts +136 -0
  49. package/src/features/automations/when-evaluator.ts +83 -0
  50. package/src/features/automations/when-pushdown.ts +53 -0
  51. package/src/features/content/handlers/create.ts +22 -10
  52. package/src/features/content/handlers/delete.ts +21 -10
  53. package/src/features/content/handlers/update.ts +21 -9
  54. package/src/features/draft/draft.handler.ts +51 -153
  55. package/src/features/draft/draft.middleware.ts +62 -0
  56. package/src/features/email/email.service.ts +13 -0
  57. package/src/features/email/email.types.ts +10 -0
  58. package/src/features/email/index.ts +2 -1
  59. package/src/features/email/templates/automation-mail.ts +15 -0
  60. package/src/features/notifications/notifications.handler.ts +25 -54
  61. package/src/features/password-reset/request.ts +17 -41
  62. package/src/features/password-reset/reset.ts +18 -54
  63. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  64. package/src/features/schema/schema.handler.ts +1 -1
  65. package/src/features/settings/settings.handler.ts +64 -176
  66. package/src/features/setup/index.ts +12 -17
  67. package/src/features/stats/stats.handler.ts +110 -138
  68. package/src/index.ts +40 -8
  69. package/src/middleware/auth-providers.middleware.ts +32 -0
  70. package/src/middleware/observability.middleware.ts +52 -0
  71. package/src/middleware/rate-limit.middleware.ts +41 -0
  72. package/src/middleware/repository.middleware.ts +72 -5
  73. package/src/middleware.ts +15 -35
  74. package/src/public/cache-utils.ts +34 -0
  75. package/src/public/entry-projection.ts +42 -0
  76. package/src/public/idempotency.ts +19 -0
  77. package/src/public/problem-details.ts +5 -0
  78. package/src/public/public-add.ts +110 -166
  79. package/src/public/public-edit.ts +4 -3
  80. package/src/public/public-read.ts +59 -216
  81. package/src/public/public-routes.ts +2 -2
  82. package/src/public/query-builder.test.ts +220 -0
  83. package/src/public/rate-limit-middleware.ts +7 -19
  84. package/src/public/read-list.ts +50 -0
  85. package/src/public/read-single.ts +44 -0
  86. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  87. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  88. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  89. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  90. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  91. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  92. package/src/search-utils.test.ts +207 -0
  93. package/src/search-utils.ts +18 -1
  94. package/src/search.ts +24 -35
  95. package/src/shared/apply-policies.test.ts +77 -0
  96. package/src/shared/automations.repository.d1.ts +146 -0
  97. package/src/shared/background-notification-service.test.ts +58 -0
  98. package/src/shared/background-notification-service.ts +48 -0
  99. package/src/shared/content-utils.test.ts +161 -0
  100. package/src/shared/content.repository.d1.test.ts +312 -0
  101. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  102. package/src/shared/d1-activity-log.repository.ts +101 -0
  103. package/src/shared/d1-activity-logger.test.ts +82 -0
  104. package/src/shared/d1-activity-logger.ts +63 -0
  105. package/src/shared/d1-analytics.repository.test.ts +74 -0
  106. package/src/shared/d1-analytics.repository.ts +81 -0
  107. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  108. package/src/shared/d1-content-scan.repository.ts +29 -0
  109. package/src/shared/d1-notification.repository.test.ts +124 -0
  110. package/src/shared/d1-notification.repository.ts +114 -0
  111. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  112. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  113. package/src/shared/d1-search.repository.test.ts +83 -0
  114. package/src/shared/d1-search.repository.ts +84 -0
  115. package/src/shared/d1-session.repository.test.ts +121 -0
  116. package/src/shared/d1-session.repository.ts +98 -0
  117. package/src/shared/d1-user.repository.test.ts +147 -0
  118. package/src/shared/d1-user.repository.ts +109 -0
  119. package/src/shared/d1-widget.repository.test.ts +217 -0
  120. package/src/shared/d1-widget.repository.ts +337 -0
  121. package/src/shared/execution-context-scheduler.ts +9 -0
  122. package/src/shared/fixed-clock.ts +21 -0
  123. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  124. package/src/shared/in-memory-activity-logger.ts +15 -0
  125. package/src/shared/in-memory-notification-service.ts +15 -0
  126. package/src/shared/media.repository.d1.test.ts +103 -0
  127. package/src/shared/media.repository.d1.ts +1 -1
  128. package/src/shared/request-utils.ts +22 -0
  129. package/src/shared/sequential-id-generator.ts +22 -0
  130. package/src/shared/storage-utils.ts +3 -3
  131. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  132. package/src/types.ts +24 -3
  133. package/src/upload.ts +17 -9
  134. package/src/widget.ts +112 -253
  135. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
  136. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  137. package/src/shared/activity-logger.ts +0 -79
  138. package/src/shared/notification-service.ts +0 -56
@@ -1,216 +1,59 @@
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
+ import type { Context } from 'hono'
2
+ import { cleanStr } from '../shared/query-utils'
3
+ import { checkPublicOperation } from './access-policy'
4
+ import { publicProblem, internalErrorDetail } from './problem-details'
5
+ import { resolveEdgeCache, withCachedResponse } from './cache-utils'
6
+ import { readSingleEntry } from './read-single'
7
+ import { readListEntries } from './read-list'
8
+ import { AppEnv } from '../types'
9
+
10
+ export async function publicReadHandler(context: Context<AppEnv>) {
11
+ const seedSlug = context.req.param('seed') ?? ''
12
+ const seed = context.get('getSeed')(seedSlug)
13
+ if (!seed) {
14
+ const available = context.get('seedRegistry').all().map(s => s.slug).join(', ')
15
+ return publicProblem(context, {
16
+ type: 'seed-not-found',
17
+ title: 'Seed Not Found',
18
+ status: 404,
19
+ detail: `The content type '${seedSlug}' does not exist. Available types: ${available}.`,
20
+ })
21
+ }
22
+
23
+ const access = checkPublicOperation(seed, 'read')
24
+ if (!access.ok) {
25
+ return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
26
+ }
27
+
28
+ const edgeCache = resolveEdgeCache(context)
29
+ const cacheKey = context.req.raw
30
+ if (edgeCache) {
31
+ const hit = await edgeCache.cache.match(cacheKey)
32
+ if (hit) return hit
33
+ }
34
+
35
+ const query = context.req.query()
36
+ const id = cleanStr(query.id)
37
+ const slug = cleanStr(query.slug)
38
+ const publishedOnly = context.env.PUBLIC_PUBLISHED_ONLY !== 'false'
39
+ const repository = context.get('repository')
40
+
41
+ try {
42
+ if (id || slug) {
43
+ const result = await readSingleEntry({ seed, seedSlug, repository, id, slug, publishedOnly, fieldsParam: query.fields })
44
+ if (!result.ok) {
45
+ return publicProblem(context, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: result.detail })
46
+ }
47
+ return withCachedResponse(edgeCache, cacheKey, context.json({ data: result.data, meta: result.meta }, 200))
48
+ }
49
+
50
+ const result = await readListEntries({ seed, seedSlug, repository, query, publishedOnly })
51
+ return withCachedResponse(edgeCache, cacheKey, context.json(result, 200))
52
+ } catch (error) {
53
+ if (error instanceof Error && error.message.startsWith('Invalid filter:')) {
54
+ return publicProblem(context, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: error.message })
55
+ }
56
+ console.error('Public read error:', error)
57
+ return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: internalErrorDetail(context.env, error) })
58
+ }
59
+ }
@@ -13,7 +13,7 @@ publicApp.get('/health', (c) => {
13
13
  /** Returns the public-facing schema: only seeds with public read or post enabled. */
14
14
  publicApp.get('/schema', (c) => {
15
15
  const registry = c.get('seedRegistry')
16
- const publicSeeds = Object.values(registry)
16
+ const publicSeeds = registry.all()
17
17
  .filter(seed => seed.allowPublicRead === true || seed.allowPublicPost === true || seed.allowPublicEdit === true)
18
18
  .map(seed => ({
19
19
  slug: seed.slug,
@@ -39,7 +39,7 @@ publicApp.get('/schema', (c) => {
39
39
  /** HTML render of the public schema for quick browser inspection. */
40
40
  publicApp.get('/schema.html', (c) => {
41
41
  const registry = c.get('seedRegistry')
42
- const publicSeeds = Object.values(registry)
42
+ const publicSeeds = registry.all()
43
43
  .filter(seed => seed.allowPublicRead === true || seed.allowPublicPost === true || seed.allowPublicEdit === true)
44
44
 
45
45
  const seedHtml = publicSeeds.map(seed => {
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ parsePublicFilter,
4
+ toEngineFilters,
5
+ parsePublicPagination,
6
+ parseLatestCount,
7
+ } from './query-builder'
8
+ import type { Seed } from '@beechcms/core'
9
+
10
+ const SEED = {
11
+ slug: 'posts',
12
+ displayNameAlias: 'title',
13
+ branches: [
14
+ { id: 'br_01', alias: 'title', type: 'text' },
15
+ { id: 'br_02', alias: 'count', type: 'number' },
16
+ { id: 'br_03', alias: 'active', type: 'boolean' },
17
+ { id: 'br_04', alias: 'published_at', type: 'date' },
18
+ { id: 'br_05', alias: 'tags', type: 'tags' },
19
+ { id: 'br_06', alias: 'body', type: 'richtext' },
20
+ { id: 'br_07', alias: 'attachment', type: 'file' },
21
+ { id: 'br_08', alias: 'meta', type: 'json' },
22
+ ],
23
+ } as unknown as Seed
24
+
25
+ // ─── parsePublicFilter ────────────────────────────────────────────────────────
26
+
27
+ describe('parsePublicFilter', () => {
28
+ it('returns null for undefined input', () => {
29
+ expect(parsePublicFilter(undefined)).toBeNull()
30
+ })
31
+
32
+ it('returns null for empty string', () => {
33
+ expect(parsePublicFilter('')).toBeNull()
34
+ })
35
+
36
+ it('parses a valid filter with explicit AND logic', () => {
37
+ const raw = JSON.stringify({ logic: 'AND', where: [{ field: 'title', op: 'eq', value: 'Hello' }] })
38
+ const result = parsePublicFilter(raw)!
39
+ expect(result.logic).toBe('AND')
40
+ expect(result.where).toHaveLength(1)
41
+ expect(result.where[0]).toEqual({ field: 'title', op: 'eq', value: 'Hello' })
42
+ })
43
+
44
+ it('defaults logic to AND when omitted', () => {
45
+ const raw = JSON.stringify({ where: [{ field: 'title', op: 'contains', value: 'x' }] })
46
+ expect(parsePublicFilter(raw)!.logic).toBe('AND')
47
+ })
48
+
49
+ it('accepts case-insensitive logic value', () => {
50
+ const raw = JSON.stringify({ logic: 'or', where: [] })
51
+ expect(parsePublicFilter(raw)!.logic).toBe('OR')
52
+ })
53
+
54
+ it('throws for malformed JSON', () => {
55
+ expect(() => parsePublicFilter('{not-json')).toThrow('malformed JSON')
56
+ })
57
+
58
+ it('throws when parsed value is not an object', () => {
59
+ expect(() => parsePublicFilter('"a string"')).toThrow('object expected')
60
+ })
61
+
62
+ it('throws for an invalid logic value', () => {
63
+ const raw = JSON.stringify({ logic: 'BOTH', where: [] })
64
+ expect(() => parsePublicFilter(raw)).toThrow("'AND' or 'OR'")
65
+ })
66
+
67
+ it('throws for an unknown operator in a where condition', () => {
68
+ const raw = JSON.stringify({ where: [{ field: 'title', op: 'like', value: 'x' }] })
69
+ expect(() => parsePublicFilter(raw)).toThrow("unknown operator 'like'")
70
+ })
71
+
72
+ it('throws when where is not an array', () => {
73
+ const raw = JSON.stringify({ where: { field: 'title', op: 'eq' } })
74
+ expect(() => parsePublicFilter(raw)).toThrow("'where' must be an array")
75
+ })
76
+
77
+ it('filters out null conditions from where (missing field/op)', () => {
78
+ const raw = JSON.stringify({ where: [null, { field: 'title', op: 'eq', value: 'x' }] })
79
+ const result = parsePublicFilter(raw)!
80
+ expect(result.where).toHaveLength(1)
81
+ })
82
+
83
+ it('accepts all valid operators without throwing', () => {
84
+ const operators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'not_contains',
85
+ 'starts_with', 'ends_with', 'is_empty', 'is_not_empty', 'in', 'not_in',
86
+ 'has_tag', 'has_any_tag', 'has_all_tags']
87
+ for (const op of operators) {
88
+ const raw = JSON.stringify({ where: [{ field: 'title', op, value: 'x' }] })
89
+ expect(() => parsePublicFilter(raw)).not.toThrow()
90
+ }
91
+ })
92
+ })
93
+
94
+ // ─── toEngineFilters ──────────────────────────────────────────────────────────
95
+
96
+ describe('toEngineFilters', () => {
97
+ it('returns [] for null filter', () => {
98
+ expect(toEngineFilters(SEED, null)).toEqual([])
99
+ })
100
+
101
+ it('returns [] when where array is empty', () => {
102
+ expect(toEngineFilters(SEED, { where: [], logic: 'AND' })).toEqual([])
103
+ })
104
+
105
+ it('maps text branch to FilterType "text"', () => {
106
+ const result = toEngineFilters(SEED, { where: [{ field: 'title', op: 'eq', value: 'Hi' }], logic: 'AND' })
107
+ expect(result[0].type).toBe('text')
108
+ })
109
+
110
+ it('maps number branch to FilterType "number"', () => {
111
+ const result = toEngineFilters(SEED, { where: [{ field: 'count', op: 'gt', value: 5 }], logic: 'AND' })
112
+ expect(result[0].type).toBe('number')
113
+ })
114
+
115
+ it('maps boolean branch to FilterType "boolean"', () => {
116
+ const result = toEngineFilters(SEED, { where: [{ field: 'active', op: 'eq', value: true }], logic: 'AND' })
117
+ expect(result[0].type).toBe('boolean')
118
+ })
119
+
120
+ it('maps date branch to FilterType "date"', () => {
121
+ const result = toEngineFilters(SEED, { where: [{ field: 'published_at', op: 'gt', value: '2024-01-01' }], logic: 'AND' })
122
+ expect(result[0].type).toBe('date')
123
+ })
124
+
125
+ it('maps tags branch to FilterType "tags"', () => {
126
+ const result = toEngineFilters(SEED, { where: [{ field: 'tags', op: 'has_tag', value: 'news' }], logic: 'AND' })
127
+ expect(result[0].type).toBe('tags')
128
+ })
129
+
130
+ it('maps richtext branch to FilterType "text"', () => {
131
+ const result = toEngineFilters(SEED, { where: [{ field: 'body', op: 'contains', value: 'x' }], logic: 'AND' })
132
+ expect(result[0].type).toBe('text')
133
+ })
134
+
135
+ it('maps file branch to FilterType "text"', () => {
136
+ const result = toEngineFilters(SEED, { where: [{ field: 'attachment', op: 'contains', value: 'url' }], logic: 'AND' })
137
+ expect(result[0].type).toBe('text')
138
+ })
139
+
140
+ it('maps json branch to FilterType "json"', () => {
141
+ const result = toEngineFilters(SEED, { where: [{ field: 'meta', op: 'eq', value: '{}' }], logic: 'AND' })
142
+ expect(result[0].type).toBe('json')
143
+ })
144
+
145
+ it('maps system columns (id, slug, status) to FilterType "system"', () => {
146
+ for (const field of ['id', 'slug', 'status', 'created_at', 'updated_at']) {
147
+ const result = toEngineFilters(SEED, { where: [{ field, op: 'eq', value: 'x' }], logic: 'AND' })
148
+ expect(result[0].type).toBe('system')
149
+ }
150
+ })
151
+
152
+ it('maps unknown fields to FilterType "text"', () => {
153
+ const result = toEngineFilters(SEED, { where: [{ field: 'ghost_field', op: 'eq', value: 'x' }], logic: 'AND' })
154
+ expect(result[0].type).toBe('text')
155
+ })
156
+
157
+ it('produces one FilterGroup per condition', () => {
158
+ const filter = {
159
+ where: [
160
+ { field: 'title', op: 'eq' as const, value: 'A' },
161
+ { field: 'count', op: 'gt' as const, value: 5 },
162
+ ],
163
+ logic: 'AND' as const,
164
+ }
165
+ expect(toEngineFilters(SEED, filter)).toHaveLength(2)
166
+ })
167
+
168
+ it('includes column and conditions in each FilterGroup', () => {
169
+ const filter = { where: [{ field: 'title', op: 'contains' as const, value: 'hi' }], logic: 'AND' as const }
170
+ const [group] = toEngineFilters(SEED, filter)
171
+ expect(group.column).toBe('title')
172
+ expect(group.conditions[0].op).toBe('contains')
173
+ expect(group.conditions[0].value).toBe('hi')
174
+ })
175
+ })
176
+
177
+ // ─── parsePublicPagination ────────────────────────────────────────────────────
178
+
179
+ describe('parsePublicPagination', () => {
180
+ it('defaults to page=1 and limit=25 when input is empty', () => {
181
+ expect(parsePublicPagination({})).toEqual({ page: 1, limit: 25 })
182
+ })
183
+
184
+ it('parses valid page and limit values', () => {
185
+ expect(parsePublicPagination({ page: '3', limit: '10' })).toEqual({ page: 3, limit: 10 })
186
+ })
187
+
188
+ it('clamps limit to a maximum of 100', () => {
189
+ expect(parsePublicPagination({ limit: '500' }).limit).toBe(100)
190
+ })
191
+
192
+ it('uses default for invalid (non-numeric) input', () => {
193
+ expect(parsePublicPagination({ page: 'abc', limit: 'xyz' })).toEqual({ page: 1, limit: 25 })
194
+ })
195
+ })
196
+
197
+ // ─── parseLatestCount ─────────────────────────────────────────────────────────
198
+
199
+ describe('parseLatestCount', () => {
200
+ it('returns 10 for undefined', () => {
201
+ expect(parseLatestCount(undefined)).toBe(10)
202
+ })
203
+
204
+ it('parses a valid integer string', () => {
205
+ expect(parseLatestCount('5')).toBe(5)
206
+ })
207
+
208
+ it('clamps to 1 for values below minimum', () => {
209
+ expect(parseLatestCount('0')).toBe(1)
210
+ expect(parseLatestCount('-5')).toBe(1)
211
+ })
212
+
213
+ it('clamps to 100 for values above maximum', () => {
214
+ expect(parseLatestCount('200')).toBe(100)
215
+ })
216
+
217
+ it('returns 10 for non-numeric input', () => {
218
+ expect(parseLatestCount('abc')).toBe(10)
219
+ })
220
+ })
@@ -1,34 +1,22 @@
1
1
  import type { Context, Next } from 'hono'
2
+ import type { AppEnv } from '../types'
2
3
  import { publicProblem } from './problem-details'
3
-
4
- type PublicBindings = {
5
- PUBLIC_READ_RATE_LIMITER?: RateLimit
6
- PUBLIC_WRITE_RATE_LIMITER?: RateLimit
7
- }
4
+ import { getClientIp } from '../shared/request-utils'
8
5
 
9
6
  function isReadMethod(method: string): boolean {
10
7
  return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
11
8
  }
12
9
 
13
- function getClientIp(headers: Headers): string {
14
- return headers.get('cf-connecting-ip') ?? 'unknown'
15
- }
16
-
17
10
  export function publicRateLimitMiddleware() {
18
- return async (c: Context, next: Next): Promise<Response | void> => {
19
- const env = c.env as PublicBindings
11
+ return async (c: Context<AppEnv>, next: Next): Promise<Response | void> => {
20
12
  const readMethod = isReadMethod(c.req.method)
21
- const limiter = readMethod ? env.PUBLIC_READ_RATE_LIMITER : env.PUBLIC_WRITE_RATE_LIMITER
22
- if (!limiter) {
23
- await next()
24
- return
25
- }
13
+ const limiterName = readMethod ? ('publicApiRead' as const) : ('publicApiWrite' as const)
26
14
 
27
15
  const seed = c.req.param('seed') ?? 'no-seed'
28
- const key = `${getClientIp(c.req.raw.headers)}:${seed}:${readMethod ? 'read' : 'write'}`
29
- const { success } = await limiter.limit({ key })
16
+ const key = `${getClientIp(c.req)}:${seed}:${limiterName}`
17
+ const result = await c.get('rateLimiters').getLimiter(limiterName).checkLimit(key)
30
18
 
31
- if (!success) {
19
+ if (!result.isAllowed) {
32
20
  return publicProblem(c, {
33
21
  type: 'rate-limit-exceeded',
34
22
  title: 'Too Many Requests',
@@ -0,0 +1,50 @@
1
+ import type { Seed, ContentRepository } from '@beechcms/core'
2
+ import { cleanStr } from '../shared/query-utils'
3
+ import { toFlatPublicEntry } from './entry-projection'
4
+ import { buildPublicListMeta } from './response-builder'
5
+ import { parsePublicFilter, parsePublicPagination, parseLatestCount, toEngineFilters } from './query-builder'
6
+
7
+ type ReadListInput = {
8
+ seed: Seed
9
+ seedSlug: string
10
+ repository: ContentRepository
11
+ query: Record<string, string | undefined>
12
+ publishedOnly: boolean
13
+ }
14
+
15
+ export async function readListEntries(input: ReadListInput) {
16
+ const { seed, seedSlug, repository, query, publishedOnly } = input
17
+
18
+ const parsedFilter = parsePublicFilter(query.filter)
19
+ const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
20
+ const latestMode = cleanStr(query.latest) !== null
21
+ const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
22
+ const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
23
+ const offset = (pagination.page - 1) * pagination.limit
24
+ const search = cleanStr(query.search) ?? ''
25
+ const engineFilters = toEngineFilters(seed, parsedFilter)
26
+ const sortBy = cleanStr(query.orderBy) ?? 'created_at'
27
+ const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
28
+
29
+ const { items, total } = await repository.findMany(seed, {
30
+ filters: engineFilters,
31
+ search: search || undefined,
32
+ status: publishedOnly ? 'published' : null,
33
+ pagination: {
34
+ limit: latestMode ? (latestCount ?? 10) : pagination.limit,
35
+ offset: latestMode ? 0 : offset,
36
+ },
37
+ orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir },
38
+ })
39
+
40
+ const data = items.map(item => toFlatPublicEntry(item, seed, query.fields))
41
+
42
+ if (latestMode) {
43
+ return { data, meta: { total, returned: data.length, seed: seedSlug } }
44
+ }
45
+
46
+ return {
47
+ data,
48
+ meta: buildPublicListMeta({ total, page: pagination.page, limit: pagination.limit, returned: data.length, seed: seedSlug }),
49
+ }
50
+ }
@@ -0,0 +1,44 @@
1
+ import { EntryNotFoundError } from '@beechcms/core'
2
+ import type { Seed, ContentRepository } from '@beechcms/core'
3
+ import { toFlatPublicEntry } from './entry-projection'
4
+ import { buildPublicSingleMeta } from './response-builder'
5
+
6
+ type ReadSingleInput = {
7
+ seed: Seed
8
+ seedSlug: string
9
+ repository: ContentRepository
10
+ id: string | null
11
+ slug: string | null
12
+ publishedOnly: boolean
13
+ fieldsParam?: string
14
+ }
15
+
16
+ export type ReadSingleResult =
17
+ | { ok: true; data: Record<string, unknown>; meta: { seed: string } }
18
+ | { ok: false; detail: string }
19
+
20
+ export async function readSingleEntry(input: ReadSingleInput): Promise<ReadSingleResult> {
21
+ const { seed, seedSlug, repository, id, slug, publishedOnly, fieldsParam } = input
22
+ const label = id ?? slug!
23
+
24
+ try {
25
+ const entry = id
26
+ ? await repository.findById(seed, id)
27
+ : await repository.findBySlug(seed, slug!)
28
+
29
+ if (publishedOnly && entry.status !== 'published') {
30
+ return { ok: false, detail: `Entry '${label}' not found or not published.` }
31
+ }
32
+
33
+ return {
34
+ ok: true,
35
+ data: toFlatPublicEntry(entry, seed, fieldsParam),
36
+ meta: buildPublicSingleMeta(seedSlug),
37
+ }
38
+ } catch (error) {
39
+ if (error instanceof EntryNotFoundError) {
40
+ return { ok: false, detail: `Entry '${label}' not found for content type '${seedSlug}'.` }
41
+ }
42
+ throw error
43
+ }
44
+ }