@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,98 @@
1
+ /**
2
+ * Tipi condivisi del modulo email di Beech CMS.
3
+ *
4
+ * Tutti i tipi usati tra provider, service e template sono definiti qui
5
+ * in modo che ogni layer rimanga disaccoppiato dagli altri.
6
+ */
7
+
8
+ // ── Locale ────────────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Lingue supportate dal sistema di template email.
12
+ *
13
+ * Per aggiungere una nuova lingua:
14
+ * 1. Aggiungi il codice ISO qui (es. `'fr'`).
15
+ * 2. Aggiungi la traduzione corrispondente nell'oggetto `COPY` di ogni
16
+ * file in `templates/`. TypeScript segnalerà le chiavi mancanti.
17
+ */
18
+ export const SUPPORTED_EMAIL_LOCALES = ['en', 'it'] as const
19
+ export type EmailLocale = (typeof SUPPORTED_EMAIL_LOCALES)[number]
20
+
21
+ /**
22
+ * Risolve una stringa locale non verificata (es. dal body di una request)
23
+ * a un valore `EmailLocale` supportato. Qualsiasi valore sconosciuto
24
+ * ricade su `'en'` in modo sicuro.
25
+ *
26
+ * @param raw - Valore grezzo dal client (può essere qualsiasi cosa).
27
+ * @returns Un `EmailLocale` valido, sempre.
28
+ */
29
+ export function resolveEmailLocale(raw: unknown): EmailLocale {
30
+ if (
31
+ typeof raw === 'string' &&
32
+ (SUPPORTED_EMAIL_LOCALES as readonly string[]).includes(raw)
33
+ ) {
34
+ return raw as EmailLocale
35
+ }
36
+ return 'en'
37
+ }
38
+
39
+ // ── Messaggio outbound ────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Il messaggio email risolto che il provider riceve e invia.
43
+ * Viene costruito dal service combinando i parametri della chiamata
44
+ * con l'output del template builder.
45
+ */
46
+ export interface OutboundEmail {
47
+ /** Indirizzo mittente in formato RFC 5321 (es. "Beech CMS <noreply@beechcms.dev>"). */
48
+ from: string
49
+ /** Lista degli indirizzi destinatari. Deve contenere almeno un elemento. */
50
+ to: string[]
51
+ subject: string
52
+ /** Corpo HTML completo. Deve essere un documento HTML valido (vedi `templates/shell.ts`). */
53
+ html: string
54
+ }
55
+
56
+ // ── Parametri delle funzioni del service ──────────────────────────────────────
57
+
58
+ /**
59
+ * Parametri condivisi da ogni funzione di invio email in `email.service.ts`.
60
+ * Le funzioni specifiche estendono questo tipo con i campi aggiuntivi
61
+ * necessari al proprio template.
62
+ */
63
+ export interface BaseEmailParams {
64
+ /** Indirizzo del destinatario principale. */
65
+ to: string
66
+ /** Lingua del corpo email. Usa `resolveEmailLocale()` prima di passarlo qui. */
67
+ locale: EmailLocale
68
+ /**
69
+ * API key Resend (o del provider attivo). Deve essere non vuota —
70
+ * il chiamante è responsabile di validarla prima di invocare il service.
71
+ */
72
+ apiKey: string
73
+ /**
74
+ * Indirizzo mittente in formato RFC 5321.
75
+ * Default: "Beech CMS <onboarding@resend.dev>" (mittente di test Resend).
76
+ * In produzione, impostare un indirizzo verificato tramite la variabile
77
+ * d'ambiente `EMAIL_FROM`.
78
+ */
79
+ from?: string
80
+ /**
81
+ * Quando `true`, gli errori del provider vengono loggati in console.
82
+ * Impostare `false` in produzione per non esporre dettagli interni.
83
+ */
84
+ isDev?: boolean
85
+ }
86
+
87
+ /** Parametri per l'email di reset password — aggiunge l'URL di reset. */
88
+ export interface PasswordResetEmailParams extends BaseEmailParams {
89
+ /**
90
+ * URL completo che l'utente clicca per impostare la nuova password.
91
+ * Contiene il token in chiaro come query param `?token=<uuid>`.
92
+ * Costruito dal chiamante come `${APP_URL}/reset-password?token=${token}`.
93
+ */
94
+ resetUrl: string
95
+ }
96
+
97
+ /** Parametri per la notifica "password modificata". Nessun campo aggiuntivo. */
98
+ export type PasswordChangedEmailParams = BaseEmailParams
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Public API — modulo email di Beech CMS
3
+ *
4
+ * Questo è l'UNICO file da importare da codice esterno a questa feature.
5
+ * I dettagli implementativi interni (provider, template, shell) sono privati
6
+ * alla slice e non devono mai essere importati direttamente dall'esterno.
7
+ *
8
+ * ─── FUNZIONI ESPORTATE ───────────────────────────────────────────────────────
9
+ * sendPasswordResetEmail — invia l'email con il link di reset
10
+ * sendPasswordChangedEmail — invia la notifica "password modificata"
11
+ *
12
+ * ─── TIPI E UTILITY ESPORTATI ────────────────────────────────────────────────
13
+ * EmailLocale — 'en' | 'it' (aggiungere lingue in email.types.ts)
14
+ * resolveEmailLocale — resolver sicuro per locale da input non verificato
15
+ * PasswordResetEmailParams — shape dei parametri per sendPasswordResetEmail
16
+ * PasswordChangedEmailParams — shape dei parametri per sendPasswordChangedEmail
17
+ */
18
+
19
+ export { sendPasswordResetEmail, sendPasswordChangedEmail } from './email.service'
20
+ export {
21
+ resolveEmailLocale,
22
+ SUPPORTED_EMAIL_LOCALES,
23
+ } from './email.types'
24
+ export type {
25
+ EmailLocale,
26
+ PasswordResetEmailParams,
27
+ PasswordChangedEmailParams,
28
+ } from './email.types'
@@ -0,0 +1,63 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { EmailProvider } from '../email.provider'
3
+ import type { OutboundEmail } from '../email.types'
4
+
5
+ /** Endpoint REST di Resend per l'invio email. */
6
+ const RESEND_API_URL = 'https://api.resend.com/emails'
7
+
8
+ /**
9
+ * Implementazione Resend di EmailProvider.
10
+ *
11
+ * Questo è l'UNICO file del modulo email che conosce Resend.
12
+ * Ogni altro file è completamente ignaro di quale provider sia attivo.
13
+ *
14
+ * ─── COME SOSTITUIRE QUESTO PROVIDER ─────────────────────────────────────────
15
+ * 1. Crea un nuovo file in `providers/` (es. `providers/sendgrid.ts`).
16
+ * 2. Esporta una classe che implementa `EmailProvider` (un solo metodo: `send`).
17
+ * 3. In `email.service.ts` sostituisci `new ResendEmailProvider(…)` con la
18
+ * tua nuova classe nella funzione `createProvider()`.
19
+ * 4. Aggiorna le variabili d'ambiente in `types.ts` e `wrangler.jsonc`.
20
+ * 5. Nessun altro file nel progetto va modificato.
21
+ *
22
+ * Documentazione API Resend: https://resend.com/docs/api-reference/emails/send-email
23
+ * ─────────────────────────────────────────────────────────────────────────────
24
+ */
25
+ export class ResendEmailProvider implements EmailProvider {
26
+ private readonly apiKey: string
27
+
28
+ /** Quando `true`, gli errori vengono loggati in console (solo in sviluppo). */
29
+ private readonly isDev: boolean
30
+
31
+ constructor(apiKey: string, isDev = false) {
32
+ this.apiKey = apiKey
33
+ this.isDev = isDev
34
+ }
35
+
36
+ /**
37
+ * Invia l'email tramite la REST API di Resend (`POST /emails`).
38
+ *
39
+ * Lancia un'eccezione se Resend risponde con uno status non-2xx, in modo
40
+ * che il chiamante (`email.service.ts`) possa decidere se propagare l'errore
41
+ * o gestirlo silenziosamente (fire-and-forget).
42
+ *
43
+ * Il corpo della response viene letto per il log solo in ambiente di sviluppo,
44
+ * per evitare di consumare il body stream in produzione inutilmente.
45
+ */
46
+ async send(email: OutboundEmail): Promise<void> {
47
+ const response = await fetch(RESEND_API_URL, {
48
+ method: 'POST',
49
+ headers: {
50
+ Authorization: `Bearer ${this.apiKey}`,
51
+ 'Content-Type': 'application/json',
52
+ },
53
+ body: JSON.stringify(email),
54
+ })
55
+
56
+ if (!response.ok) {
57
+ const detail = this.isDev
58
+ ? await response.text()
59
+ : `HTTP ${response.status}`
60
+ throw new Error(`[ResendEmailProvider] invio fallito — ${detail}`)
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,59 @@
1
+ import type { EmailLocale } from '../email.types'
2
+ import { buildEmailShell } from './shell'
3
+
4
+ /**
5
+ * Testi localizzati per la notifica di sicurezza "password modificata".
6
+ *
7
+ * ─── AGGIUNGERE UNA NUOVA LINGUA ─────────────────────────────────────────────
8
+ * Vedi le istruzioni in `templates/password-reset.ts`.
9
+ * ─────────────────────────────────────────────────────────────────────────────
10
+ */
11
+ const COPY: Record<EmailLocale, {
12
+ subject: string
13
+ title: string
14
+ body: string
15
+ warning: string
16
+ footer: string
17
+ }> = {
18
+ en: {
19
+ subject: 'Your Beech CMS password has been changed',
20
+ title: 'Your password has been changed',
21
+ body: 'Your Beech CMS account password was successfully changed. If you made this change, no action is needed.',
22
+ warning:
23
+ 'If you did not make this change, your account may be compromised. Contact your administrator immediately.',
24
+ footer: 'This is an automated security notification. Do not reply to this email.',
25
+ },
26
+ it: {
27
+ subject: 'La tua password Beech CMS è stata modificata',
28
+ title: 'La tua password è stata modificata',
29
+ body: 'La password del tuo account Beech CMS è stata modificata con successo. Se hai effettuato tu questa modifica, non devi fare nulla.',
30
+ warning:
31
+ 'Se non hai effettuato tu questa modifica, il tuo account potrebbe essere compromesso. Contatta immediatamente il tuo amministratore.',
32
+ footer: 'Questa è una notifica di sicurezza automatica. Non rispondere a questa email.',
33
+ },
34
+ }
35
+
36
+ /**
37
+ * Costruisce l'email di notifica "password modificata".
38
+ *
39
+ * Questa email non ha un pulsante CTA — è una pura notifica di sicurezza.
40
+ * Il blocco `warning` (testo rosso) avvisa l'utente di agire se non è
41
+ * stato lui a modificare la password.
42
+ *
43
+ * @param locale - Lingua per oggetto e corpo dell'email.
44
+ * @returns Oggetto con `subject` (stringa) e `html` (documento HTML completo).
45
+ */
46
+ export function buildPasswordChangedEmail(
47
+ locale: EmailLocale,
48
+ ): { subject: string; html: string } {
49
+ const c = COPY[locale]
50
+ return {
51
+ subject: c.subject,
52
+ html: buildEmailShell(locale, {
53
+ title: c.title,
54
+ body: c.body,
55
+ warning: c.warning,
56
+ footer: c.footer,
57
+ }),
58
+ }
59
+ }
@@ -0,0 +1,64 @@
1
+ import type { EmailLocale } from '../email.types'
2
+ import { buildEmailShell } from './shell'
3
+
4
+ /**
5
+ * Testi localizzati per l'email di reset password.
6
+ *
7
+ * ─── AGGIUNGERE UNA NUOVA LINGUA ─────────────────────────────────────────────
8
+ * 1. Aggiungi il codice ISO in `SUPPORTED_EMAIL_LOCALES` (email.types.ts).
9
+ * 2. Aggiungi una chiave corrispondente in questo oggetto con tutti i campi.
10
+ * TypeScript segnala immediatamente le chiavi mancanti grazie a
11
+ * `Record<EmailLocale, …>`.
12
+ * ─────────────────────────────────────────────────────────────────────────────
13
+ */
14
+ const COPY: Record<EmailLocale, {
15
+ subject: string
16
+ title: string
17
+ body: string
18
+ ctaLabel: string
19
+ footer: string
20
+ }> = {
21
+ en: {
22
+ subject: 'Reset your Beech CMS password',
23
+ title: 'Reset your password',
24
+ body: 'You requested a password reset for your Beech CMS account. Click the button below to set a new password. This link expires in 30 minutes.',
25
+ ctaLabel: 'Reset password',
26
+ footer: "If you didn't request this, you can safely ignore this email.",
27
+ },
28
+ it: {
29
+ subject: 'Reimposta la tua password Beech CMS',
30
+ title: 'Reimposta la tua password',
31
+ body: 'Hai richiesto il reset della password per il tuo account Beech CMS. Clicca il pulsante qui sotto per impostare una nuova password. Questo link scade tra 30 minuti.',
32
+ ctaLabel: 'Reimposta password',
33
+ footer: 'Se non hai richiesto questo, puoi ignorare questa email in tutta sicurezza.',
34
+ },
35
+ }
36
+
37
+ /**
38
+ * Costruisce l'email con il link di reset password.
39
+ *
40
+ * Compone il contenuto localizzato con il layout base (`buildEmailShell`)
41
+ * iniettando il pulsante CTA che punta all'URL di reset.
42
+ *
43
+ * @param resetUrl - URL completo con il token in chiaro, es.
44
+ * `https://dashboard.beechcms.dev/reset-password?token=<uuid>`.
45
+ * Viene incorporato direttamente nel pulsante CTA — non sanificare
46
+ * ulteriormente: il token è un UUID generato internamente.
47
+ * @param locale - Lingua per oggetto e corpo dell'email.
48
+ * @returns Oggetto con `subject` (stringa) e `html` (documento HTML completo).
49
+ */
50
+ export function buildPasswordResetEmail(
51
+ resetUrl: string,
52
+ locale: EmailLocale,
53
+ ): { subject: string; html: string } {
54
+ const c = COPY[locale]
55
+ return {
56
+ subject: c.subject,
57
+ html: buildEmailShell(locale, {
58
+ title: c.title,
59
+ body: c.body,
60
+ cta: { label: c.ctaLabel, href: resetUrl },
61
+ footer: c.footer,
62
+ }),
63
+ }
64
+ }
@@ -0,0 +1,93 @@
1
+ import type { EmailLocale } from '../email.types'
2
+
3
+ /**
4
+ * Slot di contenuto che ogni template deve fornire per comporre un'email completa.
5
+ * Ogni slot corrisponde a un blocco visivo nel layout della card email.
6
+ */
7
+ export interface EmailShellSlots {
8
+ /**
9
+ * Heading H2 mostrato in cima alla card. Mantienilo sotto ~50 caratteri
10
+ * per garantire una buona leggibilità su client mobile.
11
+ */
12
+ title: string
13
+
14
+ /**
15
+ * Testo principale del corpo. Renderizzato come paragrafo.
16
+ * È ammesso HTML inline sicuro (es. `<strong>`, `<a href="...">`),
17
+ * ma evita elementi block (`<p>`, `<div>`) che potrebbero rompere
18
+ * la struttura del layout in client email rigidi (Outlook, Gmail).
19
+ */
20
+ body: string
21
+
22
+ /**
23
+ * Pulsante call-to-action opzionale, renderizzato come link-button scuro.
24
+ * Ometti per email di sola notifica che non richiedono azione da parte dell'utente.
25
+ */
26
+ cta?: { label: string; href: string }
27
+
28
+ /**
29
+ * Paragrafo di avviso opzionale. Renderizzato in rosso (#ef4444) per attirare
30
+ * l'attenzione. Usalo per avvisi di sicurezza
31
+ * ("se non sei stato tu, agisci immediatamente").
32
+ */
33
+ warning?: string
34
+
35
+ /**
36
+ * Testo piccolo grigio in fondo alla card.
37
+ * Usato per note del tipo "notifica automatica, non rispondere".
38
+ */
39
+ footer: string
40
+ }
41
+
42
+ /**
43
+ * Costruisce il layout HTML base condiviso da tutte le email transazionali
44
+ * di Beech CMS.
45
+ *
46
+ * ─── FONTE UNICA DI VERITÀ PER IL BRANDING ───────────────────────────────────
47
+ * Modificare questa funzione cambia l'aspetto visivo di TUTTE le email
48
+ * in uscita contemporaneamente:
49
+ * - colore di sfondo e della card
50
+ * - stile del bordo e del border-radius
51
+ * - scala tipografica e spaziatura
52
+ * - stile del pulsante CTA
53
+ *
54
+ * Per cambiare il testo o la struttura di una email specifica, modifica invece
55
+ * il file template corrispondente (`templates/password-reset.ts`, ecc.).
56
+ * ─────────────────────────────────────────────────────────────────────────────
57
+ *
58
+ * @param locale - Usato per l'attributo `lang` del tag `<html>`.
59
+ * @param slots - Blocchi di contenuto iniettati nel layout.
60
+ * @returns Un documento HTML completo e self-contained pronto per l'invio.
61
+ */
62
+ export function buildEmailShell(locale: EmailLocale, slots: EmailShellSlots): string {
63
+ const ctaBlock = slots.cta
64
+ ? `<a href="${slots.cta.href}"
65
+ style="display:inline-block;background:#111;color:#fff;padding:12px 24px;
66
+ border-radius:6px;text-decoration:none;font-size:15px;font-weight:500;
67
+ margin-bottom:24px">
68
+ ${slots.cta.label}
69
+ </a>`
70
+ : ''
71
+
72
+ const warningBlock = slots.warning
73
+ ? `<p style="margin:0 0 24px;color:#ef4444;font-size:15px;line-height:1.5;font-weight:500">
74
+ ${slots.warning}
75
+ </p>`
76
+ : ''
77
+
78
+ return `<!DOCTYPE html>
79
+ <html lang="${locale}">
80
+ <head>
81
+ <meta charset="UTF-8">
82
+ <meta name="viewport" content="width=device-width,initial-scale=1">
83
+ </head>
84
+ <body style="font-family:sans-serif;background:#f9f9f9;margin:0;padding:32px">
85
+ <div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;
86
+ padding:32px;border:1px solid #e5e5e5">
87
+ <h2 style="margin:0 0 16px;font-size:20px;color:#111">${slots.title}</h2>
88
+ <p style="margin:0 0 24px;color:#555;font-size:15px;line-height:1.5">${slots.body}</p>
89
+ ${ctaBlock}${warningBlock}<p style="margin:0;color:#999;font-size:13px">${slots.footer}</p>
90
+ </div>
91
+ </body>
92
+ </html>`
93
+ }
@@ -0,0 +1 @@
1
+ export { notificationsApp } from './notifications.handler'
@@ -0,0 +1,88 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+
5
+ const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
6
+
7
+ // GET /notifications — lista con ETag/304
8
+ notificationsApp.get('/notifications', async (c) => {
9
+ try {
10
+ const { DB } = c.env
11
+
12
+ const stats = await DB.prepare(
13
+ 'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
14
+ ).first<{ count: number; latest: number | null; read_sum: number | null }>()
15
+
16
+ const count = stats?.count ?? 0
17
+ const latest = stats?.latest ?? 0
18
+ const readSum = stats?.read_sum ?? 0
19
+ const etag = `W/"${count}-${latest}-${readSum}"`
20
+
21
+ const ifNoneMatch = c.req.header('If-None-Match')
22
+ if (ifNoneMatch === etag) {
23
+ return new Response(null, { status: 304 })
24
+ }
25
+
26
+ const result = await DB.prepare(
27
+ 'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
28
+ ).all()
29
+
30
+ c.header('ETag', etag)
31
+ c.header('Cache-Control', 'no-cache, must-revalidate')
32
+
33
+ return c.json(result.results ?? [])
34
+ } catch (err) {
35
+ console.error('Notifications fetch error:', err)
36
+ return c.json({ error: 'Failed to fetch notifications' }, 500)
37
+ }
38
+ })
39
+
40
+
41
+ // PATCH /notifications/:id/read — segna come letta
42
+ notificationsApp.patch('/notifications/:id/read', async (c) => {
43
+ try {
44
+ const id = c.req.param('id')
45
+ const { DB } = c.env
46
+ await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').bind(id).run()
47
+ return c.json({ success: true })
48
+ } catch (err) {
49
+ return c.json({ error: 'Failed to update notification' }, 500)
50
+ }
51
+ })
52
+ // PATCH /notifications/:id/unread - Segna come non letta
53
+ notificationsApp.patch('/notifications/:id/unread', async (c) => {
54
+ try {
55
+ const id = c.req.param('id')
56
+ const { DB } = c.env
57
+ await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?').bind(id).run()
58
+ return c.json({ success: true })
59
+ } catch (err) {
60
+ return c.json({ error: 'Failed to update notification' }, 500)
61
+ }
62
+ })
63
+
64
+
65
+ // DELETE /notifications/:id — elimina notifica
66
+ notificationsApp.delete('/notifications/:id', async (c) => {
67
+ try {
68
+ const id = c.req.param('id')
69
+ const { DB } = c.env
70
+ await DB.prepare('DELETE FROM notifications WHERE id = ?').bind(id).run()
71
+ return c.json({ success: true })
72
+ } catch (err) {
73
+ return c.json({ error: 'Failed to delete notification' }, 500)
74
+ }
75
+ })
76
+
77
+
78
+ // POST /notifications/mark-all-read — segna tutte come lette
79
+ notificationsApp.post('/notifications/mark-all-read', async (c) => {
80
+ try {
81
+ const { DB } = c.env
82
+ await DB.prepare('UPDATE notifications SET is_read = 1').run()
83
+ return c.json({ success: true })
84
+ } catch (err) {
85
+ return c.json({ error: 'Failed to update notifications' }, 500)
86
+ }
87
+ })
88
+ export { notificationsApp }
@@ -0,0 +1,15 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+ import { requestPasswordReset } from './request'
5
+ import { resetPassword } from './reset'
6
+
7
+ export const passwordResetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
+
9
+ // Feature flag: lets the dashboard know whether to show the forgot-password link
10
+ passwordResetApp.get('/auth/features', (c) => {
11
+ return c.json({ passwordReset: Boolean(c.env.RESEND_API_KEY) })
12
+ })
13
+
14
+ passwordResetApp.post('/auth/forgot-password', requestPasswordReset)
15
+ passwordResetApp.post('/auth/reset-password', resetPassword)
@@ -0,0 +1,88 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Context } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+ import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
5
+
6
+ const TOKEN_EXPIRY_SECONDS = 30 * 60
7
+
8
+ async function sha256hex(text: string): Promise<string> {
9
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
10
+ return Array.from(new Uint8Array(buf))
11
+ .map(b => b.toString(16).padStart(2, '0'))
12
+ .join('')
13
+ }
14
+
15
+ export async function requestPasswordReset(
16
+ c: Context<{ Bindings: Env; Variables: Variables }>
17
+ ): Promise<Response> {
18
+ if (!c.env.RESEND_API_KEY) {
19
+ return c.json({ error: 'Not available' }, 503)
20
+ }
21
+
22
+ let body: Record<string, unknown>
23
+ try {
24
+ body = await c.req.json()
25
+ } catch {
26
+ return c.json({ error: 'Invalid request body' }, 400)
27
+ }
28
+
29
+ if (typeof body.email !== 'string' || !body.email.trim()) {
30
+ return c.json({ error: 'Invalid request' }, 400)
31
+ }
32
+
33
+ const email = body.email.trim().toLowerCase()
34
+ const locale = resolveEmailLocale(body.locale)
35
+
36
+ if (c.env.FORGOT_PASSWORD_RATE_LIMITER) {
37
+ const ip = c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
38
+ const { success } = await c.env.FORGOT_PASSWORD_RATE_LIMITER.limit({ key: ip })
39
+ if (!success) {
40
+ return c.json({ error: 'Too many requests' }, 429)
41
+ }
42
+ }
43
+
44
+ // Sempre 200 per evitare user enumeration
45
+ const user = await c.env.DB
46
+ .prepare('SELECT id FROM users WHERE email = ?')
47
+ .bind(email)
48
+ .first<{ id: string }>()
49
+
50
+ if (!user) {
51
+ return c.json({ success: true })
52
+ }
53
+
54
+ // Invalida eventuali token pendenti per lo stesso utente prima di emetterne uno nuovo
55
+ await c.env.DB
56
+ .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
57
+ .bind(user.id)
58
+ .run()
59
+
60
+ const token = crypto.randomUUID()
61
+ const tokenHash = await sha256hex(token)
62
+ const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS
63
+
64
+ await c.env.DB
65
+ .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
66
+ .bind(crypto.randomUUID(), user.id, tokenHash, expiresAt)
67
+ .run()
68
+
69
+ const appUrl = (c.env.APP_URL ?? new URL(c.req.url).origin).replace(/\/$/, '')
70
+ const resetUrl = `${appUrl}/reset-password?token=${token}`
71
+
72
+ try {
73
+ await sendPasswordResetEmail({
74
+ to: email,
75
+ resetUrl,
76
+ locale,
77
+ apiKey: c.env.RESEND_API_KEY,
78
+ from: c.env.EMAIL_FROM,
79
+ isDev: c.env.ENV !== 'production',
80
+ })
81
+ } catch (err) {
82
+ if (c.env.ENV !== 'production') {
83
+ console.error('[password-reset] invio email fallito:', err)
84
+ }
85
+ }
86
+
87
+ return c.json({ success: true })
88
+ }