@beechcms/api 0.4.0-preview.2 → 0.4.0-preview.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beechcms/api",
3
- "version": "0.4.0-preview.2",
3
+ "version": "0.4.0-preview.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/factory.ts"
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@aws-sdk/client-s3": "^3.995.0",
24
- "@beechcms/core": "^0.4.0-preview.2",
24
+ "@beechcms/core": "^0.4.0-preview.4",
25
25
  "bcryptjs": "^2.4.3",
26
26
  "hono": "^4.11.9",
27
27
  "jose": "^6.1.3"
package/src/factory.ts CHANGED
@@ -1,16 +1,91 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
3
  import { cors } from 'hono/cors'
4
+ import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
4
5
  import type { Seed } from '@beechcms/core'
5
6
  import type { Env, Variables } from './types'
6
7
 
8
+ // Imports delle rotte e middleware
9
+ import { AUTH_ERRORS } from './auth/constants'
10
+ import {
11
+ parseLoginBody,
12
+ validateLoginInput,
13
+ findUserByEmail,
14
+ verifyPassword,
15
+ DUMMY_PASSWORD_HASH,
16
+ } from './auth/login'
17
+ import {
18
+ generateRefreshToken,
19
+ saveRefreshToken,
20
+ generateAccessToken,
21
+ validateRefreshToken,
22
+ revokeRefreshToken,
23
+ } from './auth/refresh'
24
+ import { authMiddleware } from './middleware'
25
+ import { contentRoutes } from './content'
26
+ import { widgetApp } from './widget'
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 { schemaApp } from './features/schema/schema.handler'
33
+ import { notificationsApp } from './features/notifications'
34
+ import { statsApp } from './features/stats'
35
+ import { uploadRoutes, serveMediaHandler } from './upload'
36
+ import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
37
+ import { searchRouter } from "./search"
38
+
7
39
  export interface BeechConfig {
8
40
  seeds: Seed[]
9
41
  }
10
42
 
43
+ // --- Costanti e helper ---
44
+ const REFRESH_TOKEN_EXPIRY_DAYS = 7
45
+ const SECONDS_PER_DAY = 24 * 60 * 60
46
+
47
+ function isRequestSecure(url: string): boolean {
48
+ return new URL(url).protocol === 'https:'
49
+ }
50
+
51
+ function getClientIp(headers: Headers): string {
52
+ return headers.get('cf-connecting-ip') ?? 'unknown'
53
+ }
54
+
55
+ function getRefreshTokenCookieOptions(secure: boolean) {
56
+ return {
57
+ httpOnly: true,
58
+ secure,
59
+ sameSite: 'Strict' as const,
60
+ maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
61
+ path: '/auth',
62
+ }
63
+ }
64
+
65
+ function getRefreshTokenDeleteCookieOptions(secure: boolean) {
66
+ return {
67
+ httpOnly: true,
68
+ secure,
69
+ sameSite: 'Strict' as const,
70
+ path: '/auth',
71
+ }
72
+ }
73
+
74
+ function handleAuthError(c: any, err: unknown, operationName: string): Response {
75
+ if (c.env.ENV !== 'production') {
76
+ console.error(`${operationName} error:`, err)
77
+ }
78
+ return c.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
79
+ }
80
+
81
+ function extractPublicSeed(path: string): string {
82
+ const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
83
+ return match ? match[1] : ''
84
+ }
85
+
11
86
  /**
12
87
  * Builds a fully configured Hono app with the given seeds injected into context.
13
- * Seeds are available in every handler via c.get('getSeed') and c.get('seedRegistry').
88
+ * This is the main entry point for a BeechCMS project.
14
89
  */
15
90
  export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
16
91
  const registry: Record<string, Seed> = Object.fromEntries(config.seeds.map(s => [s.slug, s]))
@@ -18,14 +93,13 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
18
93
 
19
94
  const app = new Hono<{ Bindings: Env; Variables: Variables }>()
20
95
 
21
- // Inject seed registry into every request context
96
+ // 1. Core Middleware (Seeds, CORS, Security)
22
97
  app.use('*', async (c, next) => {
23
98
  c.set('getSeed', getSeedFn)
24
99
  c.set('seedRegistry', registry)
25
100
  await next()
26
101
  })
27
102
 
28
- // CORS
29
103
  app.use('*', async (c, next) => {
30
104
  const origins = (c.env.CORS_ORIGINS ?? 'http://localhost:5173')
31
105
  .split(',')
@@ -42,7 +116,6 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
42
116
  })(c, next)
43
117
  })
44
118
 
45
- // Security headers
46
119
  app.use('*', async (c, next) => {
47
120
  await next()
48
121
  c.header('X-Frame-Options', 'DENY')
@@ -52,5 +125,151 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
52
125
  c.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
53
126
  })
54
127
 
128
+ // 2. Analytics Middleware
129
+ app.use('/api/*', async (c, next) => {
130
+ await next()
131
+ if (c.req.method !== 'OPTIONS' && c.res.status >= 200 && c.res.status < 300) {
132
+ const db = c.env.DB
133
+ let executionCtx: any
134
+ try { executionCtx = c.executionCtx } catch {}
135
+
136
+ if (db && executionCtx) {
137
+ const seed = extractPublicSeed(c.req.path)
138
+ executionCtx.waitUntil((async () => {
139
+ try {
140
+ const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
141
+ await db.prepare(
142
+ `INSERT INTO analytics (day_ts, metric, seed, value)
143
+ VALUES (?, 'requests', ?, 1)
144
+ ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
145
+ ).bind(today, seed).run()
146
+ } catch (err) {
147
+ console.error('Analytics middleware error:', err)
148
+ }
149
+ })())
150
+ }
151
+ }
152
+ })
153
+
154
+ // 3. Auth Routes
155
+ app.post('/auth/login', async (c) => {
156
+ try {
157
+ let body: any
158
+ try { body = await c.req.json() } catch { return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
159
+ const credentials = parseLoginBody(body)
160
+ if (!credentials) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
161
+ const { email, password } = credentials
162
+ if (!validateLoginInput(email, password)) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
163
+
164
+ const loginLimiter = c.env.LOGIN_RATE_LIMITER
165
+ if (loginLimiter) {
166
+ const clientIp = getClientIp(c.req.raw.headers)
167
+ const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
168
+ if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
169
+ }
170
+
171
+ const { DB, JWT_SECRET } = c.env
172
+ const user = await findUserByEmail(DB, email)
173
+ const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
174
+ const isValid = await verifyPassword(password, hashToCompare)
175
+
176
+ if (!user || !isValid) return c.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
177
+
178
+ const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
179
+ const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
180
+ issuer: c.env.JWT_ISSUER,
181
+ audience: c.env.JWT_AUDIENCE,
182
+ }, userProfile?.name ?? undefined)
183
+ const refreshToken = generateRefreshToken()
184
+
185
+ await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
186
+ setCookie(c, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
187
+ return c.json({ token: accessToken, expiresIn: '15m' }, 200)
188
+ } catch (err) {
189
+ return handleAuthError(c, err, 'Login')
190
+ }
191
+ })
192
+
193
+ app.post('/auth/refresh', async (c) => {
194
+ try {
195
+ const refreshLimiter = c.env.REFRESH_RATE_LIMITER
196
+ if (refreshLimiter) {
197
+ const clientIp = getClientIp(c.req.raw.headers)
198
+ const { success } = await refreshLimiter.limit({ key: clientIp })
199
+ if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
200
+ }
201
+
202
+ const refreshToken = getCookie(c, 'refresh_token')
203
+ if (!refreshToken) return c.json({ error: 'Refresh token missing' }, 401)
204
+
205
+ const { DB, JWT_SECRET } = c.env
206
+ const validation = await validateRefreshToken(DB, refreshToken)
207
+ if (!validation.valid || !validation.userId) return c.json({ error: 'Invalid refresh token' }, 401)
208
+
209
+ const user = await DB.prepare('SELECT id, email, name FROM users WHERE id = ? LIMIT 1').bind(validation.userId).first<{ id: string; email: string; name: string | null }>()
210
+ if (!user) return c.json({ error: 'User not found' }, 401)
211
+
212
+ const revoked = await revokeRefreshToken(DB, refreshToken)
213
+ if (!revoked) return c.json({ error: 'Invalid refresh token' }, 401)
214
+
215
+ const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
216
+ issuer: c.env.JWT_ISSUER,
217
+ audience: c.env.JWT_AUDIENCE,
218
+ }, user.name ?? undefined)
219
+ const newRefreshToken = generateRefreshToken()
220
+
221
+ await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
222
+ setCookie(c, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
223
+ return c.json({ token: newAccessToken, expiresIn: '15m' }, 200)
224
+ } catch (err) {
225
+ return handleAuthError(c, err, 'Refresh')
226
+ }
227
+ })
228
+
229
+ app.post('/auth/logout', async (c) => {
230
+ try {
231
+ const refreshToken = getCookie(c, 'refresh_token')
232
+ if (refreshToken) await revokeRefreshToken(c.env.DB, refreshToken)
233
+ deleteCookie(c, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(c.req.url)))
234
+ return c.json({ message: 'Logged out' }, 200)
235
+ } catch (err) {
236
+ return handleAuthError(c, err, 'Logout')
237
+ }
238
+ })
239
+
240
+ // 4. Setup & Password Reset
241
+ app.route('/', setupApp)
242
+ app.route('/', passwordResetApp)
243
+
244
+ // 5. Protected CMS API
245
+ const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
246
+ apiProtected.use('*', async (c, next) => {
247
+ await authMiddleware(c.env.JWT_SECRET, {
248
+ issuer: c.env.JWT_ISSUER,
249
+ audience: c.env.JWT_AUDIENCE,
250
+ })(c, next)
251
+ })
252
+
253
+ apiProtected.route('/settings', settingsApp)
254
+ apiProtected.route('/schema', schemaApp)
255
+ apiProtected.route('/content', notificationsApp)
256
+ apiProtected.route('/content', statsApp)
257
+ apiProtected.route('/content', rotateFieldApp)
258
+ apiProtected.route('/content', draftApp)
259
+ apiProtected.route('/content', contentRoutes)
260
+ apiProtected.route('/widget', widgetApp)
261
+
262
+ app.route('/api', apiProtected)
263
+ app.route('/api/search', searchRouter)
264
+ app.route('/api', uploadRoutes)
265
+ app.get('/api/media/:key', (c) => serveMediaHandler(c))
266
+
267
+ // 6. Public API
268
+ const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
269
+ apiPublic.use('*', publicRateLimitMiddleware())
270
+ apiPublic.use('*', apiKeyMiddleware())
271
+ apiPublic.route('/', publicRoutes)
272
+ app.route('/api/v1/public', apiPublic)
273
+
55
274
  return app
56
275
  }
@@ -0,0 +1,16 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+
5
+ const schemaApp = new Hono<{ Bindings: Env; Variables: Variables }>()
6
+
7
+ /**
8
+ * Ritorna l'intero schema del CMS (la lista dei Seed configurati).
9
+ * Usato dalla Dashboard per generare dinamicamente il menu e le form.
10
+ */
11
+ schemaApp.get('/', async (c) => {
12
+ const registry = c.get('seedRegistry')
13
+ return c.json(Object.values(registry))
14
+ })
15
+
16
+ export { schemaApp }
package/src/index.ts CHANGED
@@ -1,344 +1,11 @@
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 ---
3
+ /**
4
+ * Entry point per lo sviluppo locale del monorepo.
5
+ * In produzione (progetto utente), viene usato worker.ts che importa createBeechApp.
6
+ */
7
+ const app = createBeechApp({ seeds: [] })
40
8
 
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] : ''
101
- }
102
-
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))
336
-
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)
9
+ app.get('/', (c) => c.text('Beech API is running (Production Ready Mode)'))
343
10
 
344
11
  export default app