@beechcms/api 0.5.0 → 0.6.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { Hono } from 'hono'
6
+ import { Receiver } from '@upstash/qstash'
7
+ import type { AppEnv } from '../../types'
8
+ import type { CreateNotificationInput } from '@beechcms/core'
9
+
10
+ export const webhooksApp = new Hono<AppEnv>()
11
+
12
+ webhooksApp.post('/qstash', async (context) => {
13
+ const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY
14
+ const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY
15
+
16
+ if (!currentSigningKey || !nextSigningKey) {
17
+ console.error('QStash webhook called but signing keys are not configured')
18
+ return context.text('Configuration error', 500)
19
+ }
20
+
21
+ const receiver = new Receiver({
22
+ currentSigningKey,
23
+ nextSigningKey,
24
+ })
25
+
26
+ const body = await context.req.text()
27
+ const signature = context.req.header('Upstash-Signature')
28
+
29
+ if (!signature) {
30
+ return context.text('Missing signature', 401)
31
+ }
32
+
33
+ try {
34
+ const isValid = await receiver.verify({
35
+ signature,
36
+ body,
37
+ })
38
+
39
+ if (!isValid) {
40
+ return context.text('Invalid signature', 401)
41
+ }
42
+ } catch (err) {
43
+ console.error('QStash signature verification failed', err)
44
+ return context.text('Invalid signature', 401)
45
+ }
46
+
47
+ try {
48
+ const input = JSON.parse(body) as CreateNotificationInput
49
+
50
+ const notificationRepository = context.get('notificationRepository')
51
+
52
+ await notificationRepository.create({
53
+ title: input.title,
54
+ message: input.message,
55
+ type: input.type ?? 'info',
56
+ })
57
+
58
+ return context.text('OK', 200)
59
+ } catch (error) {
60
+ console.error('Failed to process QStash webhook payload', error)
61
+ return context.text('Internal server error', 500)
62
+ }
63
+ })
package/src/index.ts CHANGED
@@ -1,56 +1,65 @@
1
- import { createBeechApp } from './factory'
2
- import { SeedRegistry, SystemIdGenerator } from '@beechcms/core'
3
- import { runCronAutomations } from './features/automations'
4
- import { D1AutomationRepository } from './shared/automations.repository.d1'
5
- import { D1ContentRepository } from './shared/content.repository.d1'
6
- import type { Env } from './types'
7
-
8
- let seeds: any[] = []
9
-
10
- try {
11
- // @ts-ignore
12
- const mod = await import('../seed.ts')
13
- const registry = mod.default || mod.SEED_REGISTRY || mod
14
- seeds = (typeof registry === 'object' && !Array.isArray(registry))
15
- ? Object.values(registry)
16
- : registry
17
- } catch (e) {
18
- // Fallback se seed.ts non esiste
19
- }
20
-
21
- const app = createBeechApp({ seeds })
22
-
23
- app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
24
-
25
- const validSeeds = seeds.filter((s: any) => s && typeof s === 'object' && 'slug' in s)
26
-
27
- export default {
28
- fetch: app.fetch,
29
-
30
- async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
31
- const scheduledTime = controller?.scheduledTime ?? Date.now()
32
-
33
- if (!env.DB) {
34
- console.warn('[cron] D1 binding missing. Skipping cron automations.')
35
- return
36
- }
37
-
38
- const automationRepository = new D1AutomationRepository(env.DB)
39
- const contentRepository = new D1ContentRepository(env.DB)
40
- const registry = new SeedRegistry(validSeeds)
41
- const getSeed = (slug: string) => registry.get(slug) ?? null
42
-
43
- ctx.waitUntil(
44
- runCronAutomations(
45
- {
46
- automationRepository,
47
- contentRepository,
48
- getSeed,
49
- env: env as unknown as Record<string, string | undefined>,
50
- idGenerator: SystemIdGenerator,
51
- },
52
- scheduledTime,
53
- ),
54
- )
55
- },
56
- } satisfies ExportedHandler<Env>
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { createBeechApp } from './factory'
6
+ import { SeedRegistry, SystemIdGenerator } from '@beechcms/core'
7
+ import { runCronAutomations } from './features/automations'
8
+ import { D1AutomationRepository } from './shared/automations.repository.d1'
9
+ import { D1ContentRepository } from './shared/content.repository.d1'
10
+ import { D1SeedRepository } from './shared/seed.repository.d1'
11
+ import type { Env } from './types'
12
+
13
+ const app = createBeechApp({ seeds: [] })
14
+
15
+ app.get('/', (c) => c.text('Beech API is running'))
16
+
17
+ export default {
18
+ fetch(request: Request, env: Env, ctx: ExecutionContext) {
19
+ if (env.ENV === 'development') {
20
+ const checks: Array<{ name: string; url: string }> = [
21
+ { name: 'MinIO', url: (env.R2_ENDPOINT ?? 'http://localhost:9000') + '/minio/health/live' },
22
+ { name: 'Mailpit', url: `http://${env.SMTP_HOST ?? 'localhost'}:${env.SMTP_PORT ?? '8025'}/livez` },
23
+ ]
24
+ for (const c of checks) {
25
+ fetch(c.url).catch(() => {
26
+ console.warn(
27
+ `\n⚠️ ${c.name} non raggiungibile su ${c.url}\n` +
28
+ ` Beech in dev richiede lo stack Docker completo.\n` +
29
+ ` Avvialo con: npm run dev:full\n`
30
+ )
31
+ })
32
+ }
33
+ }
34
+ return app.fetch(request, env, ctx)
35
+ },
36
+
37
+ async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
38
+ const scheduledTime = controller?.scheduledTime ?? Date.now()
39
+
40
+ if (!env.DB) {
41
+ console.warn('[cron] D1 binding missing. Skipping cron automations.')
42
+ return
43
+ }
44
+
45
+ const automationRepository = new D1AutomationRepository(env.DB)
46
+ const contentRepository = new D1ContentRepository(env.DB)
47
+ const seedRepository = new D1SeedRepository(env.DB)
48
+ const seeds = await seedRepository.listActive()
49
+ const registry = new SeedRegistry(seeds)
50
+ const getSeed = (slug: string) => registry.get(slug) ?? null
51
+
52
+ ctx.waitUntil(
53
+ runCronAutomations(
54
+ {
55
+ automationRepository,
56
+ contentRepository,
57
+ getSeed,
58
+ env: env as unknown as Record<string, string | undefined>,
59
+ idGenerator: SystemIdGenerator,
60
+ },
61
+ scheduledTime,
62
+ ),
63
+ )
64
+ },
65
+ } satisfies ExportedHandler<Env>
@@ -1,78 +1,82 @@
1
- /**
2
- * Media utils: estrazione chiavi R2 dal data di un'entry.
3
- * Usato alla cancellazione entry per eliminare i file associati da R2.
4
- *
5
- * @see docs/media-engine.md
6
- */
7
- import type { Seed } from '@beechcms/core'
8
-
9
- /** Pattern per estrarre la chiave R2 da URL in formato /api/media/KEY */
10
- const MEDIA_URL_PATTERN = /\/api\/media\/([^/?#]+)/
11
-
12
- /**
13
- * Estrae la chiave R2 da un URL di media.
14
- * Es: "https://x.com/api/media/1739123456-avatar.png" → "1739123456-avatar.png"
15
- *
16
- * @param mediaUrl - URL completo o path (es. /api/media/123-foto.png)
17
- * @returns Chiave R2 o null se l'URL non è valido
18
- */
19
- export function extractMediaKey(mediaUrl: string): string | null {
20
- const match = MEDIA_URL_PATTERN.exec(String(mediaUrl))
21
- return match ? decodeURIComponent(match[1]) : null
22
- }
23
-
24
- /**
25
- * Attraversa ricorsivamente un valore (stringa, array, oggetto) e raccoglie
26
- * tutte le chiavi R2 trovate in stringhe che matchano /api/media/KEY.
27
- */
28
- function collectMediaKeysRecursive(value: unknown, collectedKeys: Set<string>): void {
29
- if (typeof value === 'string') {
30
- const r2Key = extractMediaKey(value)
31
- if (r2Key) {
32
- collectedKeys.add(r2Key)
33
- return
34
- }
35
- // Legacy compat: campi json/file possono contenere JSON serializzato.
36
- try {
37
- const parsed = JSON.parse(value) as unknown
38
- if (parsed !== value) {
39
- collectMediaKeysRecursive(parsed, collectedKeys)
40
- }
41
- } catch {
42
- // ignore
43
- }
44
- return
45
- }
46
- if (Array.isArray(value)) {
47
- for (const item of value) collectMediaKeysRecursive(item, collectedKeys)
48
- return
49
- }
50
- if (value != null && typeof value === 'object') {
51
- for (const nestedValue of Object.values(value)) {
52
- collectMediaKeysRecursive(nestedValue, collectedKeys)
53
- }
54
- }
55
- }
56
-
57
- /**
58
- * Estrae tutte le chiavi R2 dal data di un'entry.
59
- * Cerca nei campi `file` (stringa URL) e `json` (array/oggetto con URL).
60
- * Il data è in formato DB: chiavi = branch.id (es. art_03, prd_05).
61
- *
62
- * @param seed - Schema del tipo di contenuto
63
- * @param entryData - Payload in formato DB (chiavi = branch ID)
64
- * @returns Array di chiavi R2 uniche da eliminare
65
- */
66
- export function extractMediaKeysFromData(
67
- seed: Seed,
68
- entryData: Record<string, unknown>
69
- ): string[] {
70
- const r2Keys = new Set<string>()
71
- for (const branch of seed.branches) {
72
- if (branch.type !== 'file' && branch.type !== 'json') continue
73
- const fieldValue = entryData[branch.alias]
74
- if (fieldValue == null) continue
75
- collectMediaKeysRecursive(fieldValue, r2Keys)
76
- }
77
- return [...r2Keys]
78
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /**
6
+ * Media utils: estrazione chiavi R2 dal data di un'entry.
7
+ * Usato alla cancellazione entry per eliminare i file associati da R2.
8
+ *
9
+ * @see docs/media-engine.md
10
+ */
11
+ import type { Seed } from '@beechcms/core'
12
+
13
+ /** Pattern per estrarre la chiave R2 da URL in formato /api/media/KEY */
14
+ const MEDIA_URL_PATTERN = /\/api\/media\/([^/?#]+)/
15
+
16
+ /**
17
+ * Estrae la chiave R2 da un URL di media.
18
+ * Es: "https://x.com/api/media/1739123456-avatar.png" → "1739123456-avatar.png"
19
+ *
20
+ * @param mediaUrl - URL completo o path (es. /api/media/123-foto.png)
21
+ * @returns Chiave R2 o null se l'URL non è valido
22
+ */
23
+ export function extractMediaKey(mediaUrl: string): string | null {
24
+ const match = MEDIA_URL_PATTERN.exec(String(mediaUrl))
25
+ return match ? decodeURIComponent(match[1]) : null
26
+ }
27
+
28
+ /**
29
+ * Attraversa ricorsivamente un valore (stringa, array, oggetto) e raccoglie
30
+ * tutte le chiavi R2 trovate in stringhe che matchano /api/media/KEY.
31
+ */
32
+ function collectMediaKeysRecursive(value: unknown, collectedKeys: Set<string>): void {
33
+ if (typeof value === 'string') {
34
+ const r2Key = extractMediaKey(value)
35
+ if (r2Key) {
36
+ collectedKeys.add(r2Key)
37
+ return
38
+ }
39
+ // Legacy compat: campi json/file possono contenere JSON serializzato.
40
+ try {
41
+ const parsed = JSON.parse(value) as unknown
42
+ if (parsed !== value) {
43
+ collectMediaKeysRecursive(parsed, collectedKeys)
44
+ }
45
+ } catch {
46
+ // ignore
47
+ }
48
+ return
49
+ }
50
+ if (Array.isArray(value)) {
51
+ for (const item of value) collectMediaKeysRecursive(item, collectedKeys)
52
+ return
53
+ }
54
+ if (value != null && typeof value === 'object') {
55
+ for (const nestedValue of Object.values(value)) {
56
+ collectMediaKeysRecursive(nestedValue, collectedKeys)
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Estrae tutte le chiavi R2 dal data di un'entry.
63
+ * Cerca nei campi `file` (stringa URL) e `json` (array/oggetto con URL).
64
+ * Il data è in formato DB: chiavi = branch.id (es. art_03, prd_05).
65
+ *
66
+ * @param seed - Schema del tipo di contenuto
67
+ * @param entryData - Payload in formato DB (chiavi = branch ID)
68
+ * @returns Array di chiavi R2 uniche da eliminare
69
+ */
70
+ export function extractMediaKeysFromData(
71
+ seed: Seed,
72
+ entryData: Record<string, unknown>
73
+ ): string[] {
74
+ const r2Keys = new Set<string>()
75
+ for (const branch of seed.branches) {
76
+ if (branch.type !== 'file' && branch.type !== 'json') continue
77
+ const fieldValue = entryData[branch.alias]
78
+ if (fieldValue == null) continue
79
+ collectMediaKeysRecursive(fieldValue, r2Keys)
80
+ }
81
+ return [...r2Keys]
82
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { describe, it, expect, vi } from 'vitest'
6
+ import { Hono } from 'hono'
7
+ import { authProvidersMiddleware } from './auth-providers.middleware'
8
+
9
+ describe('authProvidersMiddleware', () => {
10
+ it('throws an error if ENV is production and JWT_SECRET is the development default', async () => {
11
+ const app = new Hono()
12
+ app.use('*', authProvidersMiddleware())
13
+ app.get('/test', (c) => c.text('ok'))
14
+
15
+ const env = {
16
+ ENV: 'production',
17
+ JWT_SECRET: 'sviluppo-secret-cambiami-almeno-32-byte-per-sicurezza-hono',
18
+ }
19
+
20
+ // Since throwing in Hono middleware returns an unhandled exception or 500 error,
21
+ // let's run app.request and inspect if the error is thrown. Hono's default errorHandler
22
+ // catches it, or we can add a custom error handler to catch it.
23
+ let thrownError: Error | null = null
24
+ app.onError((err) => {
25
+ thrownError = err
26
+ return Hono.prototype.json({ error: err.message }, 500)
27
+ })
28
+
29
+ const res = await app.request('/test', undefined, env)
30
+ expect(res.status).toBe(500)
31
+ expect(thrownError).not.toBeNull()
32
+ expect(thrownError!.message).toContain('JWT_SECRET non configurato')
33
+ })
34
+
35
+ it('injects hashProvider and tokenService when configuration is correct', async () => {
36
+ const app = new Hono()
37
+ app.use('*', authProvidersMiddleware())
38
+ app.get('/test', (c) => {
39
+ const hash = c.get('hashProvider' as never)
40
+ const token = c.get('tokenService' as never)
41
+ return c.json({
42
+ hasHash: !!hash,
43
+ hasToken: !!token,
44
+ })
45
+ })
46
+
47
+ const env = {
48
+ ENV: 'development',
49
+ JWT_SECRET: 'custom-secret-key-that-is-secure-enough',
50
+ JWT_ISSUER: 'issuer',
51
+ JWT_AUDIENCE: 'audience',
52
+ }
53
+
54
+ const res = await app.request('/test', undefined, env)
55
+ expect(res.status).toBe(200)
56
+ const body = await res.json() as any
57
+ expect(body.hasHash).toBe(true)
58
+ expect(body.hasToken).toBe(true)
59
+ })
60
+
61
+ it('respects overrides when provided', async () => {
62
+ const app = new Hono()
63
+ const mockHash = { hash: vi.fn(), verify: vi.fn() }
64
+ const mockToken = { sign: vi.fn(), verify: vi.fn() }
65
+
66
+ app.use('*', authProvidersMiddleware({
67
+ hashProvider: mockHash as any,
68
+ tokenService: mockToken as any,
69
+ }))
70
+
71
+ app.get('/test', (c) => {
72
+ const hash = c.get('hashProvider' as never)
73
+ const token = c.get('tokenService' as never)
74
+ return c.json({
75
+ hashIsOverridden: hash === mockHash,
76
+ tokenIsOverridden: token === mockToken,
77
+ })
78
+ })
79
+
80
+ const res = await app.request('/test')
81
+ const body = await res.json() as any
82
+ expect(body.hashIsOverridden).toBe(true)
83
+ expect(body.tokenIsOverridden).toBe(true)
84
+ })
85
+ })
@@ -1,32 +1,45 @@
1
- import { createMiddleware } from 'hono/factory'
2
- import type { IHashProvider, ITokenService, IClock } from '@beechcms/core'
3
- import { SystemClock } from '@beechcms/core'
4
- import type { AppEnv } from '../types'
5
- import { BcryptHashProvider } from '../auth/bcrypt-hash-provider'
6
- import { JoseTokenService } from '../auth/jose-token-service'
7
-
8
- export interface AuthProviderOverrides {
9
- hashProvider?: IHashProvider
10
- tokenService?: ITokenService
11
- clock?: IClock
12
- }
13
-
14
- export const authProvidersMiddleware = (overrides?: AuthProviderOverrides) => {
15
- return createMiddleware<AppEnv>(async (context, next) => {
16
- const resolvedClock = overrides?.clock ?? SystemClock
17
- const hashProvider = overrides?.hashProvider ?? new BcryptHashProvider()
18
- const tokenService = overrides?.tokenService ?? new JoseTokenService(
19
- context.env.JWT_SECRET,
20
- {
21
- issuer: context.env.JWT_ISSUER,
22
- audience: context.env.JWT_AUDIENCE,
23
- },
24
- resolvedClock,
25
- )
26
-
27
- context.set('hashProvider', hashProvider)
28
- context.set('tokenService', tokenService)
29
-
30
- await next()
31
- })
32
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { createMiddleware } from 'hono/factory'
6
+ import type { IHashProvider, ITokenService, IClock } from '@beechcms/core'
7
+ import { SystemClock } from '@beechcms/core'
8
+ import type { AppEnv } from '../types'
9
+ import { BcryptHashProvider } from '../auth/bcrypt-hash-provider'
10
+ import { JoseTokenService } from '../auth/jose-token-service'
11
+
12
+ export interface AuthProviderOverrides {
13
+ hashProvider?: IHashProvider
14
+ tokenService?: ITokenService
15
+ clock?: IClock
16
+ }
17
+
18
+ // Valore di default presente in .dev.vars.example: mai accettabile in produzione.
19
+ const DEV_JWT_SECRET = 'sviluppo-secret-cambiami-almeno-32-byte-per-sicurezza-hono'
20
+
21
+ export const authProvidersMiddleware = (overrides?: AuthProviderOverrides) => {
22
+ return createMiddleware<AppEnv>(async (context, next) => {
23
+ if (context.env.ENV === 'production' && context.env.JWT_SECRET === DEV_JWT_SECRET) {
24
+ throw new Error(
25
+ 'JWT_SECRET non configurato: in produzione impostare un segreto univoco con `wrangler secret put JWT_SECRET`',
26
+ )
27
+ }
28
+
29
+ const resolvedClock = overrides?.clock ?? SystemClock
30
+ const hashProvider = overrides?.hashProvider ?? new BcryptHashProvider()
31
+ const tokenService = overrides?.tokenService ?? new JoseTokenService(
32
+ context.env.JWT_SECRET,
33
+ {
34
+ issuer: context.env.JWT_ISSUER,
35
+ audience: context.env.JWT_AUDIENCE,
36
+ },
37
+ resolvedClock,
38
+ )
39
+
40
+ context.set('hashProvider', hashProvider)
41
+ context.set('tokenService', tokenService)
42
+
43
+ await next()
44
+ })
45
+ }
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { describe, it, expect, vi } from 'vitest'
6
+ import { Hono } from 'hono'
7
+ import { observabilityMiddleware } from './observability.middleware'
8
+ import type { IClock, IIdGenerator } from '@beechcms/core'
9
+
10
+ describe('observabilityMiddleware', () => {
11
+ it('injects default activityLogger and background notification service when no overrides are given', async () => {
12
+ const app = new Hono()
13
+
14
+ app.use('*', async (c, next) => {
15
+ c.set('notificationRepository' as never, {} as any)
16
+ await next()
17
+ })
18
+
19
+ app.use('*', observabilityMiddleware())
20
+
21
+ app.get('/test', (c) => {
22
+ const logger = c.get('activityLogger' as never)
23
+ const notifications = c.get('notificationService' as never)
24
+ return c.json({
25
+ hasLogger: !!logger,
26
+ hasNotifications: !!notifications,
27
+ })
28
+ })
29
+
30
+ const executionCtx = {
31
+ waitUntil: vi.fn(),
32
+ passThroughOnException: vi.fn(),
33
+ }
34
+
35
+ // Mock env with DB
36
+ const res = await app.request('/test', undefined, { DB: {} }, executionCtx)
37
+ const body = await res.json() as any
38
+ expect(body.hasLogger).toBe(true)
39
+ expect(body.hasNotifications).toBe(true)
40
+ })
41
+
42
+ it('respects overrides when provided', async () => {
43
+ const app = new Hono()
44
+
45
+ const mockLogger = { log: vi.fn() }
46
+ const mockNotifications = { send: vi.fn() }
47
+ const mockClock: IClock = { now: () => 12345 }
48
+ const mockIdGen: IIdGenerator = { nextId: () => 'custom-id' }
49
+
50
+ app.use('*', observabilityMiddleware({
51
+ activityLogger: mockLogger as any,
52
+ notificationService: mockNotifications as any,
53
+ clock: mockClock,
54
+ idGenerator: mockIdGen,
55
+ }))
56
+
57
+ app.get('/test', (c) => {
58
+ const logger = c.get('activityLogger' as never)
59
+ const notifications = c.get('notificationService' as never)
60
+ return c.json({
61
+ loggerIsOverridden: logger === mockLogger,
62
+ notificationsAreOverridden: notifications === mockNotifications,
63
+ })
64
+ })
65
+
66
+ const res = await app.request('/test')
67
+ const body = await res.json() as any
68
+ expect(body.loggerIsOverridden).toBe(true)
69
+ expect(body.notificationsAreOverridden).toBe(true)
70
+ })
71
+
72
+ it('uses QStashNotificationService when QSTASH_TOKEN and APP_URL are in Env', async () => {
73
+ const app = new Hono()
74
+ app.use('*', observabilityMiddleware())
75
+
76
+ app.get('/test', (c) => {
77
+ const notifications = c.get('notificationService' as never) as any
78
+ return c.json({
79
+ type: notifications?.constructor?.name,
80
+ })
81
+ })
82
+
83
+ const env = {
84
+ QSTASH_TOKEN: 'token',
85
+ APP_URL: 'https://my-app.com',
86
+ DB: {},
87
+ }
88
+
89
+ const res = await app.request('/test', undefined, env)
90
+ const body = await res.json() as any
91
+ expect(body.type).toBe('QStashNotificationService')
92
+ })
93
+ })