@beechcms/api 0.4.0-preview.1 → 0.4.0-preview.11

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 (98) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -0
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -0
  3. package/assets/dashboard/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/assets/dashboard/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/assets/dashboard/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/assets/dashboard/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/assets/dashboard/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/assets/dashboard/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/assets/dashboard/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/assets/dashboard/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/assets/dashboard/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/assets/dashboard/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/assets/dashboard/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/assets/dashboard/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/assets/dashboard/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/assets/dashboard/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/assets/dashboard/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/assets/dashboard/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/assets/dashboard/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/assets/dashboard/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/assets/dashboard/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/assets/dashboard/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/assets/dashboard/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/assets/dashboard/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/assets/dashboard/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/assets/dashboard/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/assets/dashboard/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/assets/dashboard/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/assets/dashboard/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/assets/dashboard/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/assets/dashboard/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/assets/dashboard/assets/index-9Ch2xWJr.js +554 -0
  63. package/assets/dashboard/assets/index-ye3325L9.css +1 -0
  64. package/assets/dashboard/beechLogoDark.svg +48 -0
  65. package/assets/dashboard/index.html +18 -0
  66. package/assets/dashboard/sol.svg +3 -0
  67. package/assets/dashboard/undraw_clouds_bmtk.svg +1 -0
  68. package/assets/dashboard/undraw_enter_nwx3.svg +36 -0
  69. package/assets/dashboard/vite.svg +1 -0
  70. package/package.json +4 -3
  71. package/src/factory.ts +290 -21
  72. package/src/features/content/constants.ts +10 -0
  73. package/src/features/content/handlers/create.ts +163 -0
  74. package/src/features/content/handlers/delete.ts +85 -0
  75. package/src/features/content/handlers/facets.ts +45 -0
  76. package/src/features/content/handlers/get.ts +116 -0
  77. package/src/features/content/handlers/list.ts +88 -0
  78. package/src/features/content/handlers/update.ts +216 -0
  79. package/src/features/content/index.ts +20 -0
  80. package/src/features/draft/draft.handler.ts +203 -129
  81. package/src/features/password-reset/request.ts +1 -1
  82. package/src/features/schema/schema.handler.ts +16 -0
  83. package/src/features/settings/settings.handler.ts +18 -0
  84. package/src/index.ts +17 -337
  85. package/src/middleware/repository.middleware.ts +18 -0
  86. package/src/public/public-add.ts +72 -89
  87. package/src/public/public-edit.ts +51 -76
  88. package/src/public/public-read.ts +113 -114
  89. package/src/public/query-builder.ts +47 -136
  90. package/src/search-utils.ts +5 -1
  91. package/src/shared/base.repository.d1.ts +28 -0
  92. package/src/shared/content.repository.d1.ts +382 -0
  93. package/src/shared/idempotency.repository.d1.ts +45 -0
  94. package/src/types.ts +7 -1
  95. package/src/upload.ts +3 -7
  96. package/src/content.ts +0 -502
  97. package/src/features/draft/draft.test.ts +0 -315
  98. package/src/features/rotate-field/rotate-field.test.ts +0 -297
package/src/index.ts CHANGED
@@ -1,344 +1,24 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import type { Context } from 'hono'
4
- import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
5
1
  import { createBeechApp } from './factory'
6
- import { SEED_REGISTRY } from '@beechcms/core'
7
- import { AUTH_ERRORS } from './auth/constants'
8
- import {
9
- parseLoginBody,
10
- validateLoginInput,
11
- findUserByEmail,
12
- verifyPassword,
13
- DUMMY_PASSWORD_HASH,
14
- } from './auth/login'
15
- import {
16
- generateRefreshToken,
17
- saveRefreshToken,
18
- generateAccessToken,
19
- validateRefreshToken,
20
- revokeRefreshToken,
21
- } from './auth/refresh'
22
- import { authMiddleware } from './middleware'
23
- import { contentRoutes } from './content'
24
- import { widgetApp } from './widget'
25
- // TODO: refactor — rotate-field è una VSA slice. Il resto di index.ts (auth inline, content monolith)
26
- // va migrato a slices dedicati sotto src/features/ seguendo lo stesso pattern.
27
- import { rotateFieldApp } from './features/rotate-field'
28
- import { passwordResetApp } from './features/password-reset'
29
- import { setupApp } from './features/setup'
30
- import { draftApp } from './features/draft'
31
- import { settingsApp } from './features/settings/settings.handler'
32
- import { notificationsApp } from './features/notifications'
33
- import { statsApp } from './features/stats'
34
- import { uploadRoutes, serveMediaHandler } from './upload'
35
- import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
36
- import { searchRouter } from "./search"
37
- import type { Env, Variables } from './types'
38
2
 
39
- // --- Costanti e helper ---
40
-
41
- /** Giorni di validità del refresh token */
42
- const REFRESH_TOKEN_EXPIRY_DAYS = 7
43
-
44
- /** Secondi in un giorno (per maxAge cookie) */
45
- const SECONDS_PER_DAY = 24 * 60 * 60
46
-
47
- /** Restituisce true se la richiesta è su HTTPS */
48
- function isRequestSecure(url: string): boolean {
49
- return new URL(url).protocol === 'https:'
50
- }
51
-
52
- /** Estrae l'IP del client (header Cloudflare) o 'unknown' se non disponibile */
53
- function getClientIp(headers: Headers): string {
54
- return headers.get('cf-connecting-ip') ?? 'unknown'
55
- }
56
-
57
- /** Opzioni comuni per il cookie refresh_token */
58
- function getRefreshTokenCookieOptions(secure: boolean) {
59
- return {
60
- httpOnly: true,
61
- secure,
62
- sameSite: 'Strict' as const,
63
- maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
64
- path: '/auth',
65
- }
66
- }
67
-
68
- function getRefreshTokenDeleteCookieOptions(secure: boolean) {
69
- return {
70
- httpOnly: true,
71
- secure,
72
- sameSite: 'Strict' as const,
73
- path: '/auth',
74
- }
75
- }
76
-
77
- /** Logga l'errore solo in sviluppo e restituisce risposta 500 generica */
78
- function handleAuthError(
79
- c: Context<{ Bindings: Env; Variables: Variables }>,
80
- err: unknown,
81
- operationName: string
82
- ): Response {
83
- if (c.env.ENV !== 'production') {
84
- console.error(`${operationName} error:`, err)
85
- }
86
- return c.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
87
- }
88
-
89
- // --- App ---
90
-
91
- const app = createBeechApp({ seeds: Object.values(SEED_REGISTRY) })
92
-
93
- // Rota root di test
94
- app.get('/', (c) => c.text('Beech API is running!'))
95
-
96
- /** Estrae il seed slug dalle route pubbliche (/api/v1/public/:seed).
97
- * Restituisce '' per tutte le altre route (metrica globale). */
98
- function extractPublicSeed(path: string): string {
99
- const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
100
- return match ? match[1] : ''
3
+ /**
4
+ * Entry point per lo sviluppo locale del monorepo.
5
+ * Carica dinamicamente seed.ts o seeds.ts dalla root di apps/api se presenti.
6
+ */
7
+ let seeds: any = []
8
+
9
+ try {
10
+ // @ts-ignore
11
+ const mod = await import('../seed.ts')
12
+ const registry = mod.default || mod.SEED_REGISTRY || mod
13
+ seeds = (typeof registry === 'object' && !Array.isArray(registry))
14
+ ? Object.values(registry)
15
+ : registry
16
+ } catch (e) {
17
+ // Fallback se seed.ts non esiste
101
18
  }
102
19
 
103
- // Middleware Analytics: traccia le richieste per la dashboard (Cloudflare-style metrics)
104
- app.use('/api/*', async (c, next) => {
105
- await next()
106
-
107
- // Tracciamo solo richieste andate a buon fine (2xx) e non OPTIONS
108
- if (c.req.method !== 'OPTIONS' && c.res.status >= 200 && c.res.status < 300) {
109
- const db = c.env.DB
110
- // Protezione per ambienti (es. test) dove executionCtx non è definito
111
- let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
112
- try {
113
- executionCtx = c.executionCtx
114
- } catch {
115
- // In ambiente di test Hono lancia se non presente
116
- }
117
-
118
- if (db && executionCtx) {
119
- // Usa waitUntil per non bloccare la risposta al client
120
- const seed = extractPublicSeed(c.req.path)
121
- executionCtx.waitUntil((async () => {
122
- try {
123
- const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
124
-
125
- // Incrementa contatore richieste: seed='' per route interne, seed='articoli' per public API
126
- await db.prepare(
127
- `INSERT INTO analytics (day_ts, metric, seed, value)
128
- VALUES (?, 'requests', ?, 1)
129
- ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
130
- ).bind(today, seed).run()
131
- } catch (err) {
132
- console.error('Analytics middleware error:', err)
133
- }
134
- })())
135
- }
136
- }
137
- })
138
-
139
- // POST /auth/login: autenticazione con email e password + refresh token
140
- app.post('/auth/login', async (c) => {
141
- try {
142
- let body: unknown
143
- try {
144
- body = await c.req.json()
145
- } catch {
146
- return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
147
- }
148
-
149
- const credentials = parseLoginBody(body)
150
- if (!credentials) {
151
- return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
152
- }
153
-
154
- const { email, password } = credentials
155
- if (!validateLoginInput(email, password)) {
156
- return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
157
- }
158
-
159
- // Rate limiting: 5 tentativi per IP+email ogni 60 secondi
160
- const loginLimiter = c.env.LOGIN_RATE_LIMITER
161
- if (loginLimiter) {
162
- const clientIp = getClientIp(c.req.raw.headers)
163
- const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
164
- if (!success) {
165
- return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
166
- }
167
- }
168
-
169
- const { DB, JWT_SECRET } = c.env
170
- const user = await findUserByEmail(DB, email)
171
- // Sempre verifyPassword per evitare timing attack (utente non trovato vs password errata)
172
- const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
173
- const isValid = await verifyPassword(password, hashToCompare)
174
-
175
- if (!user || !isValid) {
176
- return c.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
177
- }
178
-
179
- // Recupera name per includerlo nel JWT
180
- const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
181
-
182
- // Genera access token (15min) e refresh token (7 giorni)
183
- const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
184
- issuer: c.env.JWT_ISSUER,
185
- audience: c.env.JWT_AUDIENCE,
186
- }, userProfile?.name ?? undefined)
187
- const refreshToken = generateRefreshToken()
188
-
189
- // Salva refresh token in DB (hashed)
190
- await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
191
-
192
- setCookie(c, 'refresh_token', refreshToken, {
193
- ...getRefreshTokenCookieOptions(isRequestSecure(c.req.url)),
194
- })
195
-
196
- // Restituisci solo access token nel body
197
- return c.json({ token: accessToken, expiresIn: '15m' }, 200)
198
- } catch (err) {
199
- return handleAuthError(c, err, 'Login')
200
- }
201
- })
202
-
203
- // POST /auth/refresh: ottieni nuovo access token usando refresh token
204
- app.post('/auth/refresh', async (c) => {
205
- try {
206
- // Rate limiting: 20 richieste per IP ogni 60 secondi
207
- const refreshLimiter = c.env.REFRESH_RATE_LIMITER
208
- if (refreshLimiter) {
209
- const clientIp = getClientIp(c.req.raw.headers)
210
- const { success } = await refreshLimiter.limit({ key: clientIp })
211
- if (!success) {
212
- return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
213
- }
214
- }
215
-
216
- // Leggi refresh token dal cookie usando helper Hono
217
- const refreshToken = getCookie(c, 'refresh_token')
218
-
219
- if (!refreshToken) {
220
- return c.json({ error: 'Refresh token missing' }, 401)
221
- }
222
-
223
- const { DB, JWT_SECRET } = c.env
224
-
225
- // Valida refresh token
226
- const validation = await validateRefreshToken(DB, refreshToken)
227
- if (!validation.valid || !validation.userId) {
228
- return c.json({ error: 'Invalid refresh token' }, 401)
229
- }
230
-
231
- // Ottieni info utente per generare nuovo access token
232
- const user = await DB.prepare(
233
- 'SELECT id, email, name FROM users WHERE id = ? LIMIT 1'
234
- ).bind(validation.userId).first<{ id: string; email: string; name: string | null }>()
235
-
236
- if (!user) {
237
- return c.json({ error: 'User not found' }, 401)
238
- }
239
-
240
- // ROTAZIONE: Invalida vecchio refresh token
241
- const revoked = await revokeRefreshToken(DB, refreshToken)
242
- if (!revoked) {
243
- // Token già consumato/revocato (race) o appena scaduto: tratta come invalido.
244
- return c.json({ error: 'Invalid refresh token' }, 401)
245
- }
246
-
247
- // Genera NUOVO access token e NUOVO refresh token
248
- const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
249
- issuer: c.env.JWT_ISSUER,
250
- audience: c.env.JWT_AUDIENCE,
251
- }, user.name ?? undefined)
252
- const newRefreshToken = generateRefreshToken()
253
-
254
- // Salva nuovo refresh token in DB
255
- await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
256
-
257
- setCookie(c, 'refresh_token', newRefreshToken, {
258
- ...getRefreshTokenCookieOptions(isRequestSecure(c.req.url)),
259
- })
260
-
261
- // Restituisci nuovo access token
262
- return c.json({ token: newAccessToken, expiresIn: '15m' }, 200)
263
- } catch (err) {
264
- return handleAuthError(c, err, 'Refresh')
265
- }
266
- })
267
-
268
- // POST /auth/logout: invalida refresh token e cancella cookie
269
- app.post('/auth/logout', async (c) => {
270
- try {
271
- // Leggi refresh token dal cookie usando helper Hono
272
- const refreshToken = getCookie(c, 'refresh_token')
273
-
274
- if (refreshToken) {
275
- await revokeRefreshToken(c.env.DB, refreshToken)
276
- }
277
-
278
- deleteCookie(c, 'refresh_token', {
279
- ...getRefreshTokenDeleteCookieOptions(isRequestSecure(c.req.url)),
280
- })
281
-
282
- return c.json({ message: 'Logged out' }, 200)
283
- } catch (err) {
284
- return handleAuthError(c, err, 'Logout')
285
- }
286
- })
287
-
288
- // First-run setup: GET /auth/setup, POST /auth/setup (pubblici, bloccati dopo il primo utente)
289
- app.route('/', setupApp)
290
-
291
- // Password reset: GET /auth/features, POST /auth/forgot-password, POST /auth/reset-password (pubblici)
292
- app.route('/', passwordResetApp)
293
-
294
- // API Settings: gestione profilo e preferenze utente, protetto da JWT
295
- const apiSettings = new Hono<{ Bindings: Env; Variables: Variables }>()
296
- apiSettings.use('*', async (c, next) => {
297
- await authMiddleware(c.env.JWT_SECRET, {
298
- issuer: c.env.JWT_ISSUER,
299
- audience: c.env.JWT_AUDIENCE,
300
- })(c, next)
301
- })
302
- apiSettings.route('/', settingsApp)
303
- app.route('/api/settings', apiSettings)
304
-
305
- // API Content: CRUD universale protetto da JWT
306
- const apiContent = new Hono<{ Bindings: Env; Variables: Variables }>()
307
- apiContent.use('*', async (c, next) => {
308
- await authMiddleware(c.env.JWT_SECRET, {
309
- issuer: c.env.JWT_ISSUER,
310
- audience: c.env.JWT_AUDIENCE,
311
- })(c, next)
312
- })
313
- // Specific routes before wildcard content routes to avoid pattern conflicts
314
- apiContent.route('/', notificationsApp)
315
- apiContent.route('/', statsApp)
316
- apiContent.route('/', rotateFieldApp)
317
- apiContent.route('/', draftApp)
318
- apiContent.route('/', contentRoutes)
319
- app.route('/api/content', apiContent)
320
- app.route('/api/search', searchRouter)
321
-
322
- // API Widget: endpoint aggregati per i widget della dashboard, protetti da JWT
323
- const apiWidget = new Hono<{ Bindings: Env; Variables: Variables }>()
324
- apiWidget.use('*', async (c, next) => {
325
- await authMiddleware(c.env.JWT_SECRET, {
326
- issuer: c.env.JWT_ISSUER,
327
- audience: c.env.JWT_AUDIENCE,
328
- })(c, next)
329
- })
330
- apiWidget.route('/', widgetApp)
331
- app.route('/api/widget', apiWidget)
332
-
333
- // API Upload: POST /api/upload (JWT) + GET /api/media/:key (pubblico)
334
- app.route('/api', uploadRoutes)
335
- app.get('/api/media/:key', (c) => serveMediaHandler(c))
20
+ const app = createBeechApp({ seeds })
336
21
 
337
- // API Pubblica: endpoint per consumatori esterni, protetti da API Key
338
- const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
339
- apiPublic.use('*', publicRateLimitMiddleware())
340
- apiPublic.use('*', apiKeyMiddleware())
341
- apiPublic.route('/', publicRoutes)
342
- app.route('/api/v1/public', apiPublic)
22
+ app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
343
23
 
344
24
  export default app
@@ -0,0 +1,18 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import { D1ContentRepository } from '../shared/content.repository.d1'
3
+ import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
4
+ import type { ContentRepository, IdempotencyRepository } from '@beechcms/core'
5
+ import type { Env, Variables } from '../types'
6
+
7
+ interface RepositoryOverrides {
8
+ repository?: ContentRepository
9
+ idempotencyRepository?: IdempotencyRepository
10
+ }
11
+
12
+ export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
13
+ return createMiddleware<{ Bindings: Env; Variables: Variables }>(async (context, next) => {
14
+ context.set('repository', overrides?.repository ?? new D1ContentRepository(context.env.DB))
15
+ context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(context.env.DB))
16
+ await next()
17
+ })
18
+ }
@@ -1,30 +1,15 @@
1
- import { isValidContentStatus } from '@beechcms/core'
2
- import type { Seed } from '@beechcms/core'
1
+ import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
3
2
  import type { Context } from 'hono'
4
3
  import { cleanStr } from '../shared/query-utils'
5
- import { buildInsertBindings } from '../shared/content-utils'
6
4
  import { checkPublicOperation } from './access-policy'
7
5
  import { publicProblem } from './problem-details'
8
6
  import { generateEntrySlug, slugify } from './slug-utils'
9
7
  import { sanitizePublicPayload } from './sanitize'
10
8
  import { createNotification } from '../shared/notification-service'
9
+ import { AppEnv } from '../types'
11
10
 
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
11
+ function errorMessage(context: Context<AppEnv>, error: unknown): string {
12
+ if (context.env.ENV !== 'production' && error instanceof Error) return error.message
28
13
  return 'An unexpected error occurred.'
29
14
  }
30
15
 
@@ -57,127 +42,125 @@ async function sha256Hex(input: string): Promise<string> {
57
42
  return toHex(digest)
58
43
  }
59
44
 
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)
45
+ export async function publicAddHandler(context: Context<AppEnv>) {
46
+ const seedSlug = context.req.param('seed') ?? ''
47
+ const seed = context.get('getSeed')(seedSlug)
94
48
  if (!seed) {
95
- return publicProblem(c, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
49
+ return publicProblem(context, {
50
+ type: 'seed-not-found',
51
+ title: 'Seed Not Found',
52
+ status: 404,
53
+ detail: `The content type '${seedSlug}' does not exist.`
54
+ })
96
55
  }
56
+
97
57
  const access = checkPublicOperation(seed, 'add')
98
58
  if (!access.ok) {
99
- return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
59
+ return publicProblem(context, {
60
+ type: 'operation-not-allowed',
61
+ title: access.error.error,
62
+ status: 403,
63
+ detail: access.error.message
64
+ })
100
65
  }
101
66
 
102
67
  let body: Record<string, unknown>
103
68
  try {
104
- const parsed = await c.req.json<unknown>()
69
+ const parsed = await context.req.json<unknown>()
105
70
  body = asRecord(parsed) ?? {}
106
71
  } catch {
107
- return publicProblem(c, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
72
+ return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
108
73
  }
109
74
 
110
75
  const rawData = asRecord(body.data)
111
76
  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" })
77
+ return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
113
78
  }
114
79
 
115
80
  const statusValue = body.status ?? 'draft'
116
81
  if (!isValidContentStatus(statusValue)) {
117
- return publicProblem(c, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
82
+ return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
118
83
  }
119
84
 
120
85
  const sanitized = sanitizePublicPayload(seed, rawData, {
121
- operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true,
86
+ operation: 'create',
87
+ allowNull: false,
88
+ requireAtLeastOneValidField: true,
89
+ enforceRequiredFields: true,
122
90
  })
91
+
123
92
  if (!sanitized.ok) {
124
93
  if (sanitized.status === 422) {
125
- return publicProblem(c, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
94
+ return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
126
95
  }
127
- return publicProblem(c, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
96
+ return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
128
97
  }
129
98
 
130
- const idempotencyKey = parseIdempotencyKey(c.req.header('Idempotency-Key'))
99
+ const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
131
100
  const entrySlug = pickSlugFromBody(body, sanitized.data)
132
101
  const finalSlug = entrySlug || crypto.randomUUID().slice(0, 8)
133
- const table = `content_${seedSlug}`
102
+ const repository = context.get('repository')
103
+ const idempotencyRepository = context.get('idempotencyRepository')
134
104
 
135
105
  try {
136
- const { DB } = c.env
137
106
  const now = Math.floor(Date.now() / 1000)
138
107
  const fingerprintPayload = JSON.stringify({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
139
108
  const fingerprint = await sha256Hex(fingerprintPayload)
140
- const idempotencyTtlSeconds = Math.max(60, Number.parseInt(c.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
109
+ const idempotencyTtlSeconds = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
141
110
 
142
111
  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.' })
112
+ const existing = await idempotencyRepository.lookup(idempotencyKey)
113
+ if (existing && existing.expiresAt >= now) {
114
+ if (existing.fingerprint !== fingerprint) {
115
+ return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
147
116
  }
148
117
  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)
118
+ try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
119
+ return context.json(parsedBody, existing.responseStatus as 201)
151
120
  }
152
121
  }
153
122
 
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
123
  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()
124
+
125
+ try {
126
+ await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
127
+ } catch (error) {
128
+ if (error instanceof SlugConflictError) {
129
+ return publicProblem(context, {
130
+ type: 'slug-conflict',
131
+ title: 'Conflict',
132
+ status: 409,
133
+ detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.`
134
+ })
135
+ }
136
+ throw error
137
+ }
167
138
 
168
139
  const responseBody = { success: true, id, slug: finalSlug }
169
140
  if (idempotencyKey) {
170
- await storeIdempotency(DB, { key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), createdAt: now, expiresAt: now + idempotencyTtlSeconds })
141
+ await idempotencyRepository.store({
142
+ key: idempotencyKey,
143
+ fingerprint,
144
+ responseStatus: 201,
145
+ responseBody: JSON.stringify(responseBody),
146
+ expiresAt: now + idempotencyTtlSeconds
147
+ })
171
148
  }
172
149
 
173
- await createNotification(c, {
150
+ await createNotification(context, {
174
151
  title: `${seed.label}: Nuovo inserimento`,
175
152
  message: `Una nuova entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") è stata aggiunta via API pubblica.`,
176
153
  type: 'success',
177
154
  })
178
155
 
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) })
156
+ return context.json(responseBody, 201)
157
+ } catch (error) {
158
+ console.error('Public add error:', error)
159
+ return publicProblem(context, {
160
+ type: 'internal-server-error',
161
+ title: 'Internal Server Error',
162
+ status: 500,
163
+ detail: errorMessage(context, error)
164
+ })
182
165
  }
183
166
  }