@beechcms/api 0.4.0-preview.9 → 0.4.0

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 (89) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -249
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
package/src/index.ts CHANGED
@@ -1,11 +1,24 @@
1
- import { createBeechApp } from './factory'
2
-
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: [] })
8
-
9
- app.get('/', (c) => c.text('Beech API is running (Production Ready Mode)'))
10
-
11
- export default app
1
+ import { createBeechApp } from './factory'
2
+
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
18
+ }
19
+
20
+ const app = createBeechApp({ seeds })
21
+
22
+ app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
23
+
24
+ export default app
@@ -1,78 +1,78 @@
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
+ /**
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
+ }
@@ -0,0 +1,24 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import { D1ContentRepository } from '../shared/content.repository.d1'
3
+ import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
4
+ import { D1MediaRepository } from '../shared/media.repository.d1'
5
+ import { D1SystemStatsRepository } from '../shared/system-stats.repository.d1'
6
+ import type { ContentRepository, IdempotencyRepository, MediaRepository, SystemStatsRepository } from '@beechcms/core'
7
+ import type { Env, Variables } from '../types'
8
+
9
+ interface RepositoryOverrides {
10
+ repository?: ContentRepository
11
+ idempotencyRepository?: IdempotencyRepository
12
+ mediaRepository?: MediaRepository
13
+ systemStatsRepository?: SystemStatsRepository
14
+ }
15
+
16
+ export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
17
+ return createMiddleware<{ Bindings: Env; Variables: Variables }>(async (context, next) => {
18
+ context.set('repository', overrides?.repository ?? new D1ContentRepository(context.env.DB))
19
+ context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(context.env.DB))
20
+ context.set('mediaRepository', overrides?.mediaRepository ?? new D1MediaRepository(context.env.DB))
21
+ context.set('systemStatsRepository', overrides?.systemStatsRepository ?? new D1SystemStatsRepository(context.env.DB))
22
+ await next()
23
+ })
24
+ }
@@ -0,0 +1,21 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import { BeechBucket } from '@beechcms/core'
3
+ import { AppEnv } from '../types'
4
+ import { createBucketProvider } from '../shared/storage/factory'
5
+
6
+ interface StorageOverrides {
7
+ bucket?: BeechBucket
8
+ }
9
+
10
+ export const storageMiddleware = (overrides?: StorageOverrides) => {
11
+ return createMiddleware<AppEnv>(async (context, next) => {
12
+ if (overrides?.bucket) {
13
+ context.set('bucket', overrides.bucket)
14
+ } else {
15
+ // Determine base URL for getUrl() only if we need to create the provider
16
+ const baseUrl = context.env.MEDIA_BASE_URL?.trim().replace(/\/$/, '') || new URL(context.req.url).origin
17
+ context.set('bucket', createBucketProvider(context.env, baseUrl))
18
+ }
19
+ await next()
20
+ })
21
+ }
package/src/middleware.ts CHANGED
@@ -1,67 +1,67 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type { Context, Next } from 'hono'
3
- import { HTTPException } from 'hono/http-exception'
4
- import { jwtVerify } from 'jose'
5
-
6
- /** Payload JWT decodificato (sub = userId, email opzionale, name opzionale) */
7
- export type JwtPayload = {
8
- sub: string
9
- email?: string
10
- name?: string
11
- }
12
-
13
- /** Variabili iniettate nel context Hono dopo auth */
14
- export type AuthVariables = {
15
- jwtPayload: JwtPayload
16
- }
17
-
18
- const UNAUTHORIZED_JSON = JSON.stringify({ error: 'Unauthorized' })
19
-
20
- function unauthorizedResponse() {
21
- return new Response(UNAUTHORIZED_JSON, {
22
- status: 401,
23
- headers: { 'Content-Type': 'application/json' },
24
- })
25
- }
26
-
27
- export type JwtVerifyOptions = {
28
- issuer?: string
29
- audience?: string
30
- }
31
-
32
- /**
33
- * Middleware di autenticazione JWT.
34
- * Intercetta Authorization: Bearer <token>, verifica con jose e JWT_SECRET.
35
- * Se valido: imposta jwtPayload nel context e chiama next().
36
- * Se invalido/mancante: lancia HTTPException 401 (gestita dal framework).
37
- */
38
- export function authMiddleware(secret: string, options: JwtVerifyOptions = {}) {
39
- return async (c: Context, next: Next): Promise<Response | void> => {
40
- const auth = c.req.header('Authorization')
41
- if (!auth?.startsWith('Bearer ')) {
42
- throw new HTTPException(401, { res: unauthorizedResponse() })
43
- }
44
-
45
- const token = auth.slice(7)
46
- if (!token) {
47
- throw new HTTPException(401, { res: unauthorizedResponse() })
48
- }
49
-
50
- try {
51
- const secretBytes = new TextEncoder().encode(secret)
52
- const { payload, protectedHeader } = await jwtVerify(token, secretBytes, {
53
- algorithms: ['HS256'],
54
- issuer: options.issuer,
55
- audience: options.audience,
56
- })
57
- // Hardening: accetta solo token JWT standard (se presente il typ)
58
- if (protectedHeader.typ && protectedHeader.typ !== 'JWT') {
59
- throw new Error('Invalid typ header')
60
- }
61
- c.set('jwtPayload', payload as JwtPayload)
62
- await next()
63
- } catch {
64
- throw new HTTPException(401, { res: unauthorizedResponse() })
65
- }
66
- }
67
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Context, Next } from 'hono'
3
+ import { HTTPException } from 'hono/http-exception'
4
+ import { jwtVerify } from 'jose'
5
+
6
+ /** Payload JWT decodificato (sub = userId, email opzionale, name opzionale) */
7
+ export type JwtPayload = {
8
+ sub: string
9
+ email?: string
10
+ name?: string
11
+ }
12
+
13
+ /** Variabili iniettate nel context Hono dopo auth */
14
+ export type AuthVariables = {
15
+ jwtPayload: JwtPayload
16
+ }
17
+
18
+ const UNAUTHORIZED_JSON = JSON.stringify({ error: 'Unauthorized' })
19
+
20
+ function unauthorizedResponse() {
21
+ return new Response(UNAUTHORIZED_JSON, {
22
+ status: 401,
23
+ headers: { 'Content-Type': 'application/json' },
24
+ })
25
+ }
26
+
27
+ export type JwtVerifyOptions = {
28
+ issuer?: string
29
+ audience?: string
30
+ }
31
+
32
+ /**
33
+ * Middleware di autenticazione JWT.
34
+ * Intercetta Authorization: Bearer <token>, verifica con jose e JWT_SECRET.
35
+ * Se valido: imposta jwtPayload nel context e chiama next().
36
+ * Se invalido/mancante: lancia HTTPException 401 (gestita dal framework).
37
+ */
38
+ export function authMiddleware(secret: string, options: JwtVerifyOptions = {}) {
39
+ return async (c: Context, next: Next): Promise<Response | void> => {
40
+ const auth = c.req.header('Authorization')
41
+ if (!auth?.startsWith('Bearer ')) {
42
+ throw new HTTPException(401, { res: unauthorizedResponse() })
43
+ }
44
+
45
+ const token = auth.slice(7)
46
+ if (!token) {
47
+ throw new HTTPException(401, { res: unauthorizedResponse() })
48
+ }
49
+
50
+ try {
51
+ const secretBytes = new TextEncoder().encode(secret)
52
+ const { payload, protectedHeader } = await jwtVerify(token, secretBytes, {
53
+ algorithms: ['HS256'],
54
+ issuer: options.issuer,
55
+ audience: options.audience,
56
+ })
57
+ // Hardening: accetta solo token JWT standard (se presente il typ)
58
+ if (protectedHeader.typ && protectedHeader.typ !== 'JWT') {
59
+ throw new Error('Invalid typ header')
60
+ }
61
+ c.set('jwtPayload', payload as JwtPayload)
62
+ await next()
63
+ } catch {
64
+ throw new HTTPException(401, { res: unauthorizedResponse() })
65
+ }
66
+ }
67
+ }
@@ -1,23 +1,23 @@
1
- import type { Seed } from '@beechcms/core'
2
-
3
- export type PublicOperation = 'read' | 'add' | 'edit'
4
-
5
- function isAllowed(seed: Seed, operation: PublicOperation): boolean {
6
- if (operation === 'read') return seed.allowPublicRead === true
7
- if (operation === 'add') return seed.allowPublicPost === true
8
- return seed.allowPublicEdit === true
9
- }
10
-
11
- export function checkPublicOperation(seed: Seed, operation: PublicOperation) {
12
- if (isAllowed(seed, operation)) {
13
- return { ok: true } as const
14
- }
15
-
16
- return {
17
- ok: false,
18
- error: {
19
- error: 'Forbidden',
20
- message: `Public ${operation.toUpperCase()} is not allowed for content type '${seed.slug}'.`,
21
- },
22
- } as const
23
- }
1
+ import type { Seed } from '@beechcms/core'
2
+
3
+ export type PublicOperation = 'read' | 'add' | 'edit'
4
+
5
+ function isAllowed(seed: Seed, operation: PublicOperation): boolean {
6
+ if (operation === 'read') return seed.allowPublicRead === true
7
+ if (operation === 'add') return seed.allowPublicPost === true
8
+ return seed.allowPublicEdit === true
9
+ }
10
+
11
+ export function checkPublicOperation(seed: Seed, operation: PublicOperation) {
12
+ if (isAllowed(seed, operation)) {
13
+ return { ok: true } as const
14
+ }
15
+
16
+ return {
17
+ ok: false,
18
+ error: {
19
+ error: 'Forbidden',
20
+ message: `Public ${operation.toUpperCase()} is not allowed for content type '${seed.slug}'.`,
21
+ },
22
+ } as const
23
+ }
@@ -1,53 +1,53 @@
1
- import type { Context, Next } from 'hono'
2
- import { PUBLIC_ERRORS } from './public-errors'
3
- import { publicProblem } from './problem-details'
4
-
5
- type PublicBindings = {
6
- PUBLIC_READ_API_KEY?: string
7
- PUBLIC_WRITE_API_KEY?: string
8
- }
9
-
10
- function isReadMethod(method: string): boolean {
11
- return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
12
- }
13
-
14
- function getConfiguredKey(env: PublicBindings, method: string): string | undefined {
15
- if (isReadMethod(method)) {
16
- return env.PUBLIC_READ_API_KEY
17
- }
18
- return env.PUBLIC_WRITE_API_KEY
19
- }
20
-
21
- /**
22
- * API key auth middleware for Public API routes.
23
- * Uses X-API-Key header only.
24
- */
25
- export function apiKeyMiddleware() {
26
- return async (c: Context, next: Next): Promise<Response | void> => {
27
- const env = c.env as PublicBindings
28
- const configuredKey = getConfiguredKey(env, c.req.method)
29
-
30
- if (!configuredKey) {
31
- return publicProblem(c, {
32
- type: 'public-api-not-configured',
33
- title: PUBLIC_ERRORS.API_KEY_FORBIDDEN.error,
34
- status: 403,
35
- detail: PUBLIC_ERRORS.API_KEY_FORBIDDEN.message,
36
- })
37
- }
38
-
39
- const providedKey = c.req.header('X-API-Key')
40
-
41
- if (!providedKey || providedKey !== configuredKey) {
42
- return publicProblem(c, {
43
- type: 'public-api-key-unauthorized',
44
- title: PUBLIC_ERRORS.API_KEY_UNAUTHORIZED.error,
45
- status: 401,
46
- detail: PUBLIC_ERRORS.API_KEY_UNAUTHORIZED.message,
47
- })
48
- }
49
-
50
- await next()
51
- }
52
- }
53
-
1
+ import type { Context, Next } from 'hono'
2
+ import { PUBLIC_ERRORS } from './public-errors'
3
+ import { publicProblem } from './problem-details'
4
+
5
+ type PublicBindings = {
6
+ PUBLIC_READ_API_KEY?: string
7
+ PUBLIC_WRITE_API_KEY?: string
8
+ }
9
+
10
+ function isReadMethod(method: string): boolean {
11
+ return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
12
+ }
13
+
14
+ function getConfiguredKey(env: PublicBindings, method: string): string | undefined {
15
+ if (isReadMethod(method)) {
16
+ return env.PUBLIC_READ_API_KEY
17
+ }
18
+ return env.PUBLIC_WRITE_API_KEY
19
+ }
20
+
21
+ /**
22
+ * API key auth middleware for Public API routes.
23
+ * Uses X-API-Key header only.
24
+ */
25
+ export function apiKeyMiddleware() {
26
+ return async (c: Context, next: Next): Promise<Response | void> => {
27
+ const env = c.env as PublicBindings
28
+ const configuredKey = getConfiguredKey(env, c.req.method)
29
+
30
+ if (!configuredKey) {
31
+ return publicProblem(c, {
32
+ type: 'public-api-not-configured',
33
+ title: PUBLIC_ERRORS.API_KEY_FORBIDDEN.error,
34
+ status: 403,
35
+ detail: PUBLIC_ERRORS.API_KEY_FORBIDDEN.message,
36
+ })
37
+ }
38
+
39
+ const providedKey = c.req.header('X-API-Key')
40
+
41
+ if (!providedKey || providedKey !== configuredKey) {
42
+ return publicProblem(c, {
43
+ type: 'public-api-key-unauthorized',
44
+ title: PUBLIC_ERRORS.API_KEY_UNAUTHORIZED.error,
45
+ status: 401,
46
+ detail: PUBLIC_ERRORS.API_KEY_UNAUTHORIZED.message,
47
+ })
48
+ }
49
+
50
+ await next()
51
+ }
52
+ }
53
+
@@ -1,12 +1,12 @@
1
- export { apiKeyMiddleware } from './api-key-middleware'
2
- export { publicRateLimitMiddleware } from './rate-limit-middleware'
3
- export { publicRoutes } from './public-routes'
4
- export { PUBLIC_ERRORS } from './public-errors'
5
- export { sanitizePublicPayload } from './sanitize'
6
- export { generateEntrySlug, slugify } from './slug-utils'
7
- export { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
8
- export { parseLatestCount, parsePublicPagination } from './query-builder'
9
- export { publicReadHandler } from './public-read'
10
- export { publicAddHandler } from './public-add'
11
- export { publicEditHandler } from './public-edit'
12
-
1
+ export { apiKeyMiddleware } from './api-key-middleware'
2
+ export { publicRateLimitMiddleware } from './rate-limit-middleware'
3
+ export { publicRoutes } from './public-routes'
4
+ export { PUBLIC_ERRORS } from './public-errors'
5
+ export { sanitizePublicPayload } from './sanitize'
6
+ export { generateEntrySlug, slugify } from './slug-utils'
7
+ export { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
8
+ export { parseLatestCount, parsePublicPagination } from './query-builder'
9
+ export { publicReadHandler } from './public-read'
10
+ export { publicAddHandler } from './public-add'
11
+ export { publicEditHandler } from './public-edit'
12
+
@@ -1,42 +1,48 @@
1
- import type { Context } from 'hono'
2
-
3
- export interface PublicProblemDetailItem {
4
- field: string
5
- expected: string
6
- received: string
7
- message: string
8
- }
9
-
10
- type PublicProblemInput = {
11
- type: string
12
- title: string
13
- status: 400 | 401 | 403 | 404 | 405 | 409 | 422 | 429 | 500 | 501
14
- detail: string
15
- errors?: PublicProblemDetailItem[]
16
- }
17
-
18
- function normalizeProblemType(type: string): string {
19
- if (type.startsWith('http://') || type.startsWith('https://')) {
20
- return type
21
- }
22
- return `https://beechcms.dev/problems/${type}`
23
- }
24
-
25
- /**
26
- * Restituisce errori API in formato Problem Details (RFC 9457).
27
- */
28
- export function publicProblem(c: Context, input: PublicProblemInput): Response {
29
- const body: Record<string, unknown> = {
30
- type: normalizeProblemType(input.type),
31
- title: input.title,
32
- status: input.status,
33
- detail: input.detail,
34
- instance: c.req.path,
35
- }
36
- if (input.errors && input.errors.length > 0) {
37
- body.errors = input.errors
38
- }
39
- return c.json(body, input.status, {
40
- 'Content-Type': 'application/problem+json',
41
- })
42
- }
1
+ import type { Context } from 'hono'
2
+
3
+ export interface PublicProblemDetailItem {
4
+ field: string
5
+ expected: string
6
+ received: string
7
+ message: string
8
+ }
9
+
10
+ type PublicProblemInput = {
11
+ type: string
12
+ title: string
13
+ status: 400 | 401 | 403 | 404 | 405 | 409 | 422 | 429 | 500 | 501
14
+ detail: string
15
+ errors?: PublicProblemDetailItem[]
16
+ }
17
+
18
+ /**
19
+ * TODO: UPDATE DOMAIN ONCE REGISTERED
20
+ * Currently uses 'beechcms.dev' as a placeholder.
21
+ * When a real domain is registered for BeechCMS, update the URL below
22
+ * to point to the official API error documentation (RFC 9457).
23
+ */
24
+ function normalizeProblemType(type: string): string {
25
+ if (type.startsWith('http://') || type.startsWith('https://')) {
26
+ return type
27
+ }
28
+ return `https://beechcms.dev/problems/${type}`
29
+ }
30
+
31
+ /**
32
+ * Restituisce errori API in formato Problem Details (RFC 9457).
33
+ */
34
+ export function publicProblem(c: Context, input: PublicProblemInput): Response {
35
+ const body: Record<string, unknown> = {
36
+ type: normalizeProblemType(input.type),
37
+ title: input.title,
38
+ status: input.status,
39
+ detail: input.detail,
40
+ instance: c.req.path,
41
+ }
42
+ if (input.errors && input.errors.length > 0) {
43
+ body.errors = input.errors
44
+ }
45
+ return c.json(body, input.status, {
46
+ 'Content-Type': 'application/problem+json',
47
+ })
48
+ }