@beechcms/api 0.4.0-preview.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 (61) hide show
  1. package/README.md +21 -0
  2. package/migrations/0000_v040_base.sql +213 -0
  3. package/package.json +36 -0
  4. package/src/auth/constants.ts +10 -0
  5. package/src/auth/login.ts +91 -0
  6. package/src/auth/refresh.ts +127 -0
  7. package/src/content.ts +502 -0
  8. package/src/factory.ts +56 -0
  9. package/src/features/draft/draft.handler.ts +198 -0
  10. package/src/features/draft/draft.test.ts +315 -0
  11. package/src/features/draft/index.ts +1 -0
  12. package/src/features/email/email.provider.ts +38 -0
  13. package/src/features/email/email.service.ts +80 -0
  14. package/src/features/email/email.types.ts +98 -0
  15. package/src/features/email/index.ts +28 -0
  16. package/src/features/email/providers/resend.ts +63 -0
  17. package/src/features/email/templates/password-changed.ts +59 -0
  18. package/src/features/email/templates/password-reset.ts +64 -0
  19. package/src/features/email/templates/shell.ts +93 -0
  20. package/src/features/notifications/index.ts +1 -0
  21. package/src/features/notifications/notifications.handler.ts +88 -0
  22. package/src/features/password-reset/index.ts +15 -0
  23. package/src/features/password-reset/request.ts +88 -0
  24. package/src/features/password-reset/reset.ts +110 -0
  25. package/src/features/rotate-field/index.ts +1 -0
  26. package/src/features/rotate-field/rotate-field.handler.ts +82 -0
  27. package/src/features/rotate-field/rotate-field.schema.ts +9 -0
  28. package/src/features/rotate-field/rotate-field.test.ts +297 -0
  29. package/src/features/settings/settings.handler.ts +249 -0
  30. package/src/features/setup/index.ts +59 -0
  31. package/src/features/stats/index.ts +1 -0
  32. package/src/features/stats/stats.handler.ts +395 -0
  33. package/src/index.ts +344 -0
  34. package/src/media-utils.ts +78 -0
  35. package/src/middleware.ts +67 -0
  36. package/src/public/access-policy.ts +23 -0
  37. package/src/public/api-key-middleware.ts +53 -0
  38. package/src/public/index.ts +12 -0
  39. package/src/public/problem-details.ts +42 -0
  40. package/src/public/public-add.ts +183 -0
  41. package/src/public/public-edit.ts +183 -0
  42. package/src/public/public-errors.ts +15 -0
  43. package/src/public/public-read.ts +217 -0
  44. package/src/public/public-routes.ts +31 -0
  45. package/src/public/query-builder.ts +241 -0
  46. package/src/public/rate-limit-middleware.ts +42 -0
  47. package/src/public/response-builder.ts +26 -0
  48. package/src/public/sanitize.ts +65 -0
  49. package/src/public/slug-utils.ts +14 -0
  50. package/src/search-utils.ts +188 -0
  51. package/src/search.ts +72 -0
  52. package/src/shared/activity-logger.ts +79 -0
  53. package/src/shared/apply-policies.ts +63 -0
  54. package/src/shared/content-utils.ts +108 -0
  55. package/src/shared/fts-sync.ts +4 -0
  56. package/src/shared/notification-service.ts +56 -0
  57. package/src/shared/query-utils.ts +137 -0
  58. package/src/shared/storage-utils.ts +36 -0
  59. package/src/types.ts +35 -0
  60. package/src/upload.ts +335 -0
  61. package/src/widget.ts +349 -0
@@ -0,0 +1,183 @@
1
+ import { isValidContentStatus } 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 { buildInsertBindings } from '../shared/content-utils'
6
+ import { checkPublicOperation } from './access-policy'
7
+ import { publicProblem } from './problem-details'
8
+ import { generateEntrySlug, 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
+ PUBLIC_IDEMPOTENCY_TTL_SECONDS?: string
17
+ ENV?: string
18
+ }
19
+
20
+ type Variables = {
21
+ jwtPayload: { sub: string; email?: string }
22
+ getSeed: (slug: string) => Seed | null
23
+ seedRegistry: Record<string, Seed>
24
+ }
25
+
26
+ function errorMessage(c: Context<{ Bindings: Bindings; Variables: Variables }>, err: unknown): string {
27
+ if (c.env.ENV !== 'production' && err instanceof Error) return err.message
28
+ return 'An unexpected error occurred.'
29
+ }
30
+
31
+ function asRecord(value: unknown): Record<string, unknown> | null {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
33
+ ? (value as Record<string, unknown>)
34
+ : null
35
+ }
36
+
37
+ function pickSlugFromBody(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
38
+ const explicitSlug = cleanStr(body.slug)
39
+ if (explicitSlug) return slugify(explicitSlug)
40
+ return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
41
+ }
42
+
43
+ function parseIdempotencyKey(rawValue: string | undefined): string | null {
44
+ if (!rawValue) return null
45
+ const key = rawValue.trim()
46
+ if (!key || key.length > 128) return null
47
+ return key
48
+ }
49
+
50
+ function toHex(buffer: ArrayBuffer): string {
51
+ return [...new Uint8Array(buffer)].map((v) => v.toString(16).padStart(2, '0')).join('')
52
+ }
53
+
54
+ async function sha256Hex(input: string): Promise<string> {
55
+ const data = new TextEncoder().encode(input)
56
+ const digest = await crypto.subtle.digest('SHA-256', data)
57
+ return toHex(digest)
58
+ }
59
+
60
+ type StoredIdempotencyRecord = {
61
+ idempotency_key: string
62
+ request_fingerprint: string
63
+ response_status: number
64
+ response_body: string
65
+ expires_at: number
66
+ }
67
+
68
+ async function lookupIdempotency(db: D1Database, key: string): Promise<StoredIdempotencyRecord | null> {
69
+ return db.prepare(
70
+ `SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
71
+ FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
72
+ ).bind(key).first<StoredIdempotencyRecord>()
73
+ }
74
+
75
+ async function storeIdempotency(db: D1Database, input: {
76
+ key: string; fingerprint: string; responseStatus: number
77
+ responseBody: string; expiresAt: number; createdAt: number
78
+ }): Promise<void> {
79
+ await db.prepare(
80
+ `INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
81
+ VALUES (?, ?, ?, ?, ?, ?)
82
+ ON CONFLICT(idempotency_key) DO UPDATE SET
83
+ request_fingerprint = excluded.request_fingerprint,
84
+ response_status = excluded.response_status,
85
+ response_body = excluded.response_body,
86
+ created_at = excluded.created_at,
87
+ expires_at = excluded.expires_at`
88
+ ).bind(input.key, input.fingerprint, input.responseStatus, input.responseBody, input.createdAt, input.expiresAt).run()
89
+ }
90
+
91
+ export async function publicAddHandler(c: Context<{ Bindings: Bindings; Variables: Variables }>) {
92
+ const seedSlug = c.req.param('seed') ?? ''
93
+ const seed = c.get('getSeed')(seedSlug)
94
+ if (!seed) {
95
+ return publicProblem(c, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
96
+ }
97
+ const access = checkPublicOperation(seed, 'add')
98
+ if (!access.ok) {
99
+ return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
100
+ }
101
+
102
+ let body: Record<string, unknown>
103
+ try {
104
+ const parsed = await c.req.json<unknown>()
105
+ body = asRecord(parsed) ?? {}
106
+ } catch {
107
+ return publicProblem(c, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
108
+ }
109
+
110
+ const rawData = asRecord(body.data)
111
+ if (!rawData || Object.keys(rawData).length === 0) {
112
+ return publicProblem(c, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
113
+ }
114
+
115
+ const statusValue = body.status ?? 'draft'
116
+ if (!isValidContentStatus(statusValue)) {
117
+ return publicProblem(c, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
118
+ }
119
+
120
+ const sanitized = sanitizePublicPayload(seed, rawData, {
121
+ operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true,
122
+ })
123
+ if (!sanitized.ok) {
124
+ if (sanitized.status === 422) {
125
+ return publicProblem(c, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
126
+ }
127
+ return publicProblem(c, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
128
+ }
129
+
130
+ const idempotencyKey = parseIdempotencyKey(c.req.header('Idempotency-Key'))
131
+ const entrySlug = pickSlugFromBody(body, sanitized.data)
132
+ const finalSlug = entrySlug || crypto.randomUUID().slice(0, 8)
133
+ const table = `content_${seedSlug}`
134
+
135
+ try {
136
+ const { DB } = c.env
137
+ const now = Math.floor(Date.now() / 1000)
138
+ const fingerprintPayload = JSON.stringify({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
139
+ const fingerprint = await sha256Hex(fingerprintPayload)
140
+ const idempotencyTtlSeconds = Math.max(60, Number.parseInt(c.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
141
+
142
+ if (idempotencyKey) {
143
+ const existing = await lookupIdempotency(DB, idempotencyKey)
144
+ if (existing && existing.expires_at >= now) {
145
+ if (existing.request_fingerprint !== fingerprint) {
146
+ return publicProblem(c, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
147
+ }
148
+ let parsedBody: unknown = null
149
+ try { parsedBody = JSON.parse(existing.response_body) } catch { parsedBody = { success: true } }
150
+ return c.json(parsedBody, existing.response_status as 201)
151
+ }
152
+ }
153
+
154
+ const slugExisting = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ? LIMIT 1`).bind(finalSlug).first<{ id: string }>()
155
+ if (slugExisting) {
156
+ return publicProblem(c, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
157
+ }
158
+
159
+ const id = crypto.randomUUID()
160
+ const { cols, placeholders, bindings } = buildInsertBindings(seed, sanitized.data)
161
+ const systemCols = ['id', 'slug', 'status', 'created_at', 'updated_at']
162
+ const systemVals = [id, finalSlug, statusValue, now, now]
163
+ const allCols = [...systemCols, ...cols].join(', ')
164
+ const allPlaceholders = [...systemVals.map(() => '?'), ...placeholders].join(', ')
165
+
166
+ await DB.prepare(`INSERT INTO ${table} (${allCols}) VALUES (${allPlaceholders})`).bind(...systemVals, ...bindings).run()
167
+
168
+ const responseBody = { success: true, id, slug: finalSlug }
169
+ if (idempotencyKey) {
170
+ await storeIdempotency(DB, { key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), createdAt: now, expiresAt: now + idempotencyTtlSeconds })
171
+ }
172
+
173
+ await createNotification(c, {
174
+ title: `${seed.label}: Nuovo inserimento`,
175
+ message: `Una nuova entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") è stata aggiunta via API pubblica.`,
176
+ type: 'success',
177
+ })
178
+
179
+ return c.json(responseBody, 201)
180
+ } catch (err) {
181
+ return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(c, err) })
182
+ }
183
+ }
@@ -0,0 +1,183 @@
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
+ }
@@ -0,0 +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
+
@@ -0,0 +1,217 @@
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
+ }
@@ -0,0 +1,31 @@
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
+