@beechcms/api 0.4.0 → 0.4.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 (86) hide show
  1. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  2. package/assets/dashboard/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/auth/bcrypt-hash-provider.ts +20 -0
  5. package/src/auth/constants.ts +3 -3
  6. package/src/auth/generate-refresh-token.test.ts +19 -0
  7. package/src/auth/hash-provider.test.ts +46 -0
  8. package/src/auth/in-memory-hash-provider.ts +13 -0
  9. package/src/auth/jose-token-service.ts +55 -0
  10. package/src/auth/login.test.ts +92 -0
  11. package/src/auth/login.ts +15 -32
  12. package/src/auth/refresh.ts +0 -122
  13. package/src/auth/static-token-service.ts +18 -0
  14. package/src/auth/token-service.test.ts +82 -0
  15. package/src/factory.ts +70 -78
  16. package/src/features/content/handlers/create.ts +14 -10
  17. package/src/features/content/handlers/delete.ts +13 -9
  18. package/src/features/content/handlers/update.ts +13 -9
  19. package/src/features/draft/draft.handler.ts +23 -12
  20. package/src/features/notifications/notifications.handler.ts +25 -54
  21. package/src/features/password-reset/request.ts +17 -41
  22. package/src/features/password-reset/reset.ts +18 -54
  23. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  24. package/src/features/schema/schema.handler.ts +1 -1
  25. package/src/features/settings/settings.handler.ts +62 -175
  26. package/src/features/setup/index.ts +12 -17
  27. package/src/features/stats/stats.handler.ts +110 -138
  28. package/src/middleware/auth-providers.middleware.ts +32 -0
  29. package/src/middleware/observability.middleware.ts +52 -0
  30. package/src/middleware/rate-limit.middleware.ts +41 -0
  31. package/src/middleware/repository.middleware.ts +41 -5
  32. package/src/middleware.ts +15 -35
  33. package/src/public/public-add.ts +5 -15
  34. package/src/public/public-edit.ts +4 -3
  35. package/src/public/public-read.ts +3 -3
  36. package/src/public/public-routes.ts +2 -2
  37. package/src/public/query-builder.test.ts +220 -0
  38. package/src/public/rate-limit-middleware.ts +7 -19
  39. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  40. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  41. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  42. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  43. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  44. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  45. package/src/search-utils.test.ts +207 -0
  46. package/src/search-utils.ts +18 -1
  47. package/src/search.ts +24 -35
  48. package/src/shared/apply-policies.test.ts +77 -0
  49. package/src/shared/background-notification-service.test.ts +58 -0
  50. package/src/shared/background-notification-service.ts +48 -0
  51. package/src/shared/content-utils.test.ts +161 -0
  52. package/src/shared/content.repository.d1.test.ts +312 -0
  53. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  54. package/src/shared/d1-activity-log.repository.ts +101 -0
  55. package/src/shared/d1-activity-logger.test.ts +82 -0
  56. package/src/shared/d1-activity-logger.ts +63 -0
  57. package/src/shared/d1-analytics.repository.test.ts +74 -0
  58. package/src/shared/d1-analytics.repository.ts +81 -0
  59. package/src/shared/d1-content-scan.repository.ts +29 -0
  60. package/src/shared/d1-notification.repository.test.ts +124 -0
  61. package/src/shared/d1-notification.repository.ts +114 -0
  62. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  63. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  64. package/src/shared/d1-search.repository.test.ts +83 -0
  65. package/src/shared/d1-search.repository.ts +84 -0
  66. package/src/shared/d1-session.repository.test.ts +121 -0
  67. package/src/shared/d1-session.repository.ts +98 -0
  68. package/src/shared/d1-user.repository.test.ts +147 -0
  69. package/src/shared/d1-user.repository.ts +109 -0
  70. package/src/shared/d1-widget.repository.test.ts +217 -0
  71. package/src/shared/d1-widget.repository.ts +337 -0
  72. package/src/shared/fixed-clock.ts +21 -0
  73. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  74. package/src/shared/in-memory-activity-logger.ts +15 -0
  75. package/src/shared/in-memory-notification-service.ts +15 -0
  76. package/src/shared/media.repository.d1.test.ts +103 -0
  77. package/src/shared/media.repository.d1.ts +1 -1
  78. package/src/shared/request-utils.ts +22 -0
  79. package/src/shared/sequential-id-generator.ts +22 -0
  80. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  81. package/src/types.ts +20 -3
  82. package/src/upload.ts +14 -7
  83. package/src/widget.ts +112 -253
  84. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  85. package/src/shared/activity-logger.ts +0 -79
  86. package/src/shared/notification-service.ts +0 -56
package/src/middleware.ts CHANGED
@@ -1,20 +1,14 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import type { Context, Next } from 'hono'
3
3
  import { HTTPException } from 'hono/http-exception'
4
- import { jwtVerify } from 'jose'
4
+ import type { Env, Variables } from './types'
5
5
 
6
- /** Payload JWT decodificato (sub = userId, email opzionale, name opzionale) */
7
6
  export type JwtPayload = {
8
7
  sub: string
9
8
  email?: string
10
9
  name?: string
11
10
  }
12
11
 
13
- /** Variabili iniettate nel context Hono dopo auth */
14
- export type AuthVariables = {
15
- jwtPayload: JwtPayload
16
- }
17
-
18
12
  const UNAUTHORIZED_JSON = JSON.stringify({ error: 'Unauthorized' })
19
13
 
20
14
  function unauthorizedResponse() {
@@ -24,44 +18,30 @@ function unauthorizedResponse() {
24
18
  })
25
19
  }
26
20
 
27
- export type JwtVerifyOptions = {
28
- issuer?: string
29
- audience?: string
30
- }
31
-
32
21
  /**
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).
22
+ * JWT authentication middleware. Reads the Bearer token from the Authorization
23
+ * header and delegates verification to the ITokenService injected in context.
24
+ * Returns 401 on any missing or invalid token; never exposes failure details.
37
25
  */
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 ')) {
26
+ export function authMiddleware() {
27
+ return async (c: Context<{ Bindings: Env; Variables: Variables }>, next: Next): Promise<Response | void> => {
28
+ const authHeader = c.req.header('Authorization')
29
+ if (!authHeader?.startsWith('Bearer ')) {
42
30
  throw new HTTPException(401, { res: unauthorizedResponse() })
43
31
  }
44
32
 
45
- const token = auth.slice(7)
33
+ const token = authHeader.slice(7)
46
34
  if (!token) {
47
35
  throw new HTTPException(401, { res: unauthorizedResponse() })
48
36
  }
49
37
 
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 {
38
+ const claims = await c.get('tokenService').verify(token)
39
+
40
+ if (!claims) {
64
41
  throw new HTTPException(401, { res: unauthorizedResponse() })
65
42
  }
43
+
44
+ c.set('jwtPayload', claims as JwtPayload)
45
+ await next()
66
46
  }
67
47
  }
@@ -1,11 +1,10 @@
1
- import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
1
+ import { isValidContentStatus, SlugConflictError, sha256hex } from '@beechcms/core'
2
2
  import type { Context } from 'hono'
3
3
  import { cleanStr } from '../shared/query-utils'
4
4
  import { checkPublicOperation } from './access-policy'
5
5
  import { publicProblem } from './problem-details'
6
6
  import { generateEntrySlug, slugify } from './slug-utils'
7
7
  import { sanitizePublicPayload } from './sanitize'
8
- import { createNotification } from '../shared/notification-service'
9
8
  import { AppEnv } from '../types'
10
9
 
11
10
  function errorMessage(context: Context<AppEnv>, error: unknown): string {
@@ -32,15 +31,6 @@ function parseIdempotencyKey(rawValue: string | undefined): string | null {
32
31
  return key
33
32
  }
34
33
 
35
- function toHex(buffer: ArrayBuffer): string {
36
- return [...new Uint8Array(buffer)].map((v) => v.toString(16).padStart(2, '0')).join('')
37
- }
38
-
39
- async function sha256Hex(input: string): Promise<string> {
40
- const data = new TextEncoder().encode(input)
41
- const digest = await crypto.subtle.digest('SHA-256', data)
42
- return toHex(digest)
43
- }
44
34
 
45
35
  export async function publicAddHandler(context: Context<AppEnv>) {
46
36
  const seedSlug = context.req.param('seed') ?? ''
@@ -98,14 +88,14 @@ export async function publicAddHandler(context: Context<AppEnv>) {
98
88
 
99
89
  const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
100
90
  const entrySlug = pickSlugFromBody(body, sanitized.data)
101
- const finalSlug = entrySlug || crypto.randomUUID().slice(0, 8)
91
+ const finalSlug = entrySlug || context.get('idGenerator').uuid().slice(0, 8)
102
92
  const repository = context.get('repository')
103
93
  const idempotencyRepository = context.get('idempotencyRepository')
104
94
 
105
95
  try {
106
96
  const now = Math.floor(Date.now() / 1000)
107
97
  const fingerprintPayload = JSON.stringify({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
108
- const fingerprint = await sha256Hex(fingerprintPayload)
98
+ const fingerprint = await sha256hex(fingerprintPayload)
109
99
  const idempotencyTtlSeconds = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
110
100
 
111
101
  if (idempotencyKey) {
@@ -120,7 +110,7 @@ export async function publicAddHandler(context: Context<AppEnv>) {
120
110
  }
121
111
  }
122
112
 
123
- const id = crypto.randomUUID()
113
+ const id = context.get('idGenerator').uuid()
124
114
 
125
115
  try {
126
116
  await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
@@ -147,7 +137,7 @@ export async function publicAddHandler(context: Context<AppEnv>) {
147
137
  })
148
138
  }
149
139
 
150
- await createNotification(context, {
140
+ context.get('notificationService').notify({
151
141
  title: `${seed.label}: New entry`,
152
142
  message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
153
143
  type: 'success',
@@ -6,7 +6,6 @@ import { checkPublicOperation } from './access-policy'
6
6
  import { publicProblem } from './problem-details'
7
7
  import { slugify } from './slug-utils'
8
8
  import { sanitizePublicPayload } from './sanitize'
9
- import { createNotification } from '../shared/notification-service'
10
9
  import { AppEnv } from '../types'
11
10
 
12
11
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@@ -77,7 +76,9 @@ function resolveData(
77
76
 
78
77
  const sensitiveAliases = Object.keys(rawData).filter((alias) => {
79
78
  const branch = seed.branches.find((b) => b.alias === alias)
80
- return branch != null && resolvePolicies(branch).privacy !== 'plain'
79
+ if (!branch) return false
80
+ const policies = resolvePolicies(branch)
81
+ return policies.privacy !== 'plain' || policies.public === false
81
82
  })
82
83
  if (sensitiveAliases.length > 0) {
83
84
  return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
@@ -141,7 +142,7 @@ export async function publicEditHandler(context: PublicCtx) {
141
142
 
142
143
  await repository.update(seed, id, updateData, statusResult.value)
143
144
 
144
- await createNotification(context, {
145
+ context.get('notificationService').notify({
145
146
  title: `${seed.label}: Update`,
146
147
  message: `The entry "${slugResult.value.nextSlug}" has been modified via the public API.`,
147
148
  type: 'info',
@@ -1,5 +1,5 @@
1
1
  import { resolvePolicies, EntryNotFoundError } from '@beechcms/core'
2
- import type { Seed } from '@beechcms/core'
2
+ import type { Seed, ISeedRegistry } from '@beechcms/core'
3
3
  import type { Context } from 'hono'
4
4
  import { cleanStr } from '../shared/query-utils'
5
5
  import { checkPublicOperation } from './access-policy'
@@ -13,8 +13,8 @@ import {
13
13
  } from './query-builder'
14
14
  import { AppEnv } from '../types'
15
15
 
16
- function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
17
- const available = Object.keys(seedRegistry).join(', ')
16
+ function buildSeedNotFoundMessage(seed: string, seedRegistry: ISeedRegistry): string {
17
+ const available = seedRegistry.all().map(s => s.slug).join(', ')
18
18
  return `The content type '${seed}' does not exist. Available types: ${available}.`
19
19
  }
20
20
 
@@ -13,7 +13,7 @@ publicApp.get('/health', (c) => {
13
13
  /** Returns the public-facing schema: only seeds with public read or post enabled. */
14
14
  publicApp.get('/schema', (c) => {
15
15
  const registry = c.get('seedRegistry')
16
- const publicSeeds = Object.values(registry)
16
+ const publicSeeds = registry.all()
17
17
  .filter(seed => seed.allowPublicRead === true || seed.allowPublicPost === true || seed.allowPublicEdit === true)
18
18
  .map(seed => ({
19
19
  slug: seed.slug,
@@ -39,7 +39,7 @@ publicApp.get('/schema', (c) => {
39
39
  /** HTML render of the public schema for quick browser inspection. */
40
40
  publicApp.get('/schema.html', (c) => {
41
41
  const registry = c.get('seedRegistry')
42
- const publicSeeds = Object.values(registry)
42
+ const publicSeeds = registry.all()
43
43
  .filter(seed => seed.allowPublicRead === true || seed.allowPublicPost === true || seed.allowPublicEdit === true)
44
44
 
45
45
  const seedHtml = publicSeeds.map(seed => {
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ parsePublicFilter,
4
+ toEngineFilters,
5
+ parsePublicPagination,
6
+ parseLatestCount,
7
+ } from './query-builder'
8
+ import type { Seed } from '@beechcms/core'
9
+
10
+ const SEED = {
11
+ slug: 'posts',
12
+ displayNameAlias: 'title',
13
+ branches: [
14
+ { id: 'br_01', alias: 'title', type: 'text' },
15
+ { id: 'br_02', alias: 'count', type: 'number' },
16
+ { id: 'br_03', alias: 'active', type: 'boolean' },
17
+ { id: 'br_04', alias: 'published_at', type: 'date' },
18
+ { id: 'br_05', alias: 'tags', type: 'tags' },
19
+ { id: 'br_06', alias: 'body', type: 'richtext' },
20
+ { id: 'br_07', alias: 'attachment', type: 'file' },
21
+ { id: 'br_08', alias: 'meta', type: 'json' },
22
+ ],
23
+ } as unknown as Seed
24
+
25
+ // ─── parsePublicFilter ────────────────────────────────────────────────────────
26
+
27
+ describe('parsePublicFilter', () => {
28
+ it('returns null for undefined input', () => {
29
+ expect(parsePublicFilter(undefined)).toBeNull()
30
+ })
31
+
32
+ it('returns null for empty string', () => {
33
+ expect(parsePublicFilter('')).toBeNull()
34
+ })
35
+
36
+ it('parses a valid filter with explicit AND logic', () => {
37
+ const raw = JSON.stringify({ logic: 'AND', where: [{ field: 'title', op: 'eq', value: 'Hello' }] })
38
+ const result = parsePublicFilter(raw)!
39
+ expect(result.logic).toBe('AND')
40
+ expect(result.where).toHaveLength(1)
41
+ expect(result.where[0]).toEqual({ field: 'title', op: 'eq', value: 'Hello' })
42
+ })
43
+
44
+ it('defaults logic to AND when omitted', () => {
45
+ const raw = JSON.stringify({ where: [{ field: 'title', op: 'contains', value: 'x' }] })
46
+ expect(parsePublicFilter(raw)!.logic).toBe('AND')
47
+ })
48
+
49
+ it('accepts case-insensitive logic value', () => {
50
+ const raw = JSON.stringify({ logic: 'or', where: [] })
51
+ expect(parsePublicFilter(raw)!.logic).toBe('OR')
52
+ })
53
+
54
+ it('throws for malformed JSON', () => {
55
+ expect(() => parsePublicFilter('{not-json')).toThrow('malformed JSON')
56
+ })
57
+
58
+ it('throws when parsed value is not an object', () => {
59
+ expect(() => parsePublicFilter('"a string"')).toThrow('object expected')
60
+ })
61
+
62
+ it('throws for an invalid logic value', () => {
63
+ const raw = JSON.stringify({ logic: 'BOTH', where: [] })
64
+ expect(() => parsePublicFilter(raw)).toThrow("'AND' or 'OR'")
65
+ })
66
+
67
+ it('throws for an unknown operator in a where condition', () => {
68
+ const raw = JSON.stringify({ where: [{ field: 'title', op: 'like', value: 'x' }] })
69
+ expect(() => parsePublicFilter(raw)).toThrow("unknown operator 'like'")
70
+ })
71
+
72
+ it('throws when where is not an array', () => {
73
+ const raw = JSON.stringify({ where: { field: 'title', op: 'eq' } })
74
+ expect(() => parsePublicFilter(raw)).toThrow("'where' must be an array")
75
+ })
76
+
77
+ it('filters out null conditions from where (missing field/op)', () => {
78
+ const raw = JSON.stringify({ where: [null, { field: 'title', op: 'eq', value: 'x' }] })
79
+ const result = parsePublicFilter(raw)!
80
+ expect(result.where).toHaveLength(1)
81
+ })
82
+
83
+ it('accepts all valid operators without throwing', () => {
84
+ const operators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'not_contains',
85
+ 'starts_with', 'ends_with', 'is_empty', 'is_not_empty', 'in', 'not_in',
86
+ 'has_tag', 'has_any_tag', 'has_all_tags']
87
+ for (const op of operators) {
88
+ const raw = JSON.stringify({ where: [{ field: 'title', op, value: 'x' }] })
89
+ expect(() => parsePublicFilter(raw)).not.toThrow()
90
+ }
91
+ })
92
+ })
93
+
94
+ // ─── toEngineFilters ──────────────────────────────────────────────────────────
95
+
96
+ describe('toEngineFilters', () => {
97
+ it('returns [] for null filter', () => {
98
+ expect(toEngineFilters(SEED, null)).toEqual([])
99
+ })
100
+
101
+ it('returns [] when where array is empty', () => {
102
+ expect(toEngineFilters(SEED, { where: [], logic: 'AND' })).toEqual([])
103
+ })
104
+
105
+ it('maps text branch to FilterType "text"', () => {
106
+ const result = toEngineFilters(SEED, { where: [{ field: 'title', op: 'eq', value: 'Hi' }], logic: 'AND' })
107
+ expect(result[0].type).toBe('text')
108
+ })
109
+
110
+ it('maps number branch to FilterType "number"', () => {
111
+ const result = toEngineFilters(SEED, { where: [{ field: 'count', op: 'gt', value: 5 }], logic: 'AND' })
112
+ expect(result[0].type).toBe('number')
113
+ })
114
+
115
+ it('maps boolean branch to FilterType "boolean"', () => {
116
+ const result = toEngineFilters(SEED, { where: [{ field: 'active', op: 'eq', value: true }], logic: 'AND' })
117
+ expect(result[0].type).toBe('boolean')
118
+ })
119
+
120
+ it('maps date branch to FilterType "date"', () => {
121
+ const result = toEngineFilters(SEED, { where: [{ field: 'published_at', op: 'gt', value: '2024-01-01' }], logic: 'AND' })
122
+ expect(result[0].type).toBe('date')
123
+ })
124
+
125
+ it('maps tags branch to FilterType "tags"', () => {
126
+ const result = toEngineFilters(SEED, { where: [{ field: 'tags', op: 'has_tag', value: 'news' }], logic: 'AND' })
127
+ expect(result[0].type).toBe('tags')
128
+ })
129
+
130
+ it('maps richtext branch to FilterType "text"', () => {
131
+ const result = toEngineFilters(SEED, { where: [{ field: 'body', op: 'contains', value: 'x' }], logic: 'AND' })
132
+ expect(result[0].type).toBe('text')
133
+ })
134
+
135
+ it('maps file branch to FilterType "text"', () => {
136
+ const result = toEngineFilters(SEED, { where: [{ field: 'attachment', op: 'contains', value: 'url' }], logic: 'AND' })
137
+ expect(result[0].type).toBe('text')
138
+ })
139
+
140
+ it('maps json branch to FilterType "json"', () => {
141
+ const result = toEngineFilters(SEED, { where: [{ field: 'meta', op: 'eq', value: '{}' }], logic: 'AND' })
142
+ expect(result[0].type).toBe('json')
143
+ })
144
+
145
+ it('maps system columns (id, slug, status) to FilterType "system"', () => {
146
+ for (const field of ['id', 'slug', 'status', 'created_at', 'updated_at']) {
147
+ const result = toEngineFilters(SEED, { where: [{ field, op: 'eq', value: 'x' }], logic: 'AND' })
148
+ expect(result[0].type).toBe('system')
149
+ }
150
+ })
151
+
152
+ it('maps unknown fields to FilterType "text"', () => {
153
+ const result = toEngineFilters(SEED, { where: [{ field: 'ghost_field', op: 'eq', value: 'x' }], logic: 'AND' })
154
+ expect(result[0].type).toBe('text')
155
+ })
156
+
157
+ it('produces one FilterGroup per condition', () => {
158
+ const filter = {
159
+ where: [
160
+ { field: 'title', op: 'eq' as const, value: 'A' },
161
+ { field: 'count', op: 'gt' as const, value: 5 },
162
+ ],
163
+ logic: 'AND' as const,
164
+ }
165
+ expect(toEngineFilters(SEED, filter)).toHaveLength(2)
166
+ })
167
+
168
+ it('includes column and conditions in each FilterGroup', () => {
169
+ const filter = { where: [{ field: 'title', op: 'contains' as const, value: 'hi' }], logic: 'AND' as const }
170
+ const [group] = toEngineFilters(SEED, filter)
171
+ expect(group.column).toBe('title')
172
+ expect(group.conditions[0].op).toBe('contains')
173
+ expect(group.conditions[0].value).toBe('hi')
174
+ })
175
+ })
176
+
177
+ // ─── parsePublicPagination ────────────────────────────────────────────────────
178
+
179
+ describe('parsePublicPagination', () => {
180
+ it('defaults to page=1 and limit=25 when input is empty', () => {
181
+ expect(parsePublicPagination({})).toEqual({ page: 1, limit: 25 })
182
+ })
183
+
184
+ it('parses valid page and limit values', () => {
185
+ expect(parsePublicPagination({ page: '3', limit: '10' })).toEqual({ page: 3, limit: 10 })
186
+ })
187
+
188
+ it('clamps limit to a maximum of 100', () => {
189
+ expect(parsePublicPagination({ limit: '500' }).limit).toBe(100)
190
+ })
191
+
192
+ it('uses default for invalid (non-numeric) input', () => {
193
+ expect(parsePublicPagination({ page: 'abc', limit: 'xyz' })).toEqual({ page: 1, limit: 25 })
194
+ })
195
+ })
196
+
197
+ // ─── parseLatestCount ─────────────────────────────────────────────────────────
198
+
199
+ describe('parseLatestCount', () => {
200
+ it('returns 10 for undefined', () => {
201
+ expect(parseLatestCount(undefined)).toBe(10)
202
+ })
203
+
204
+ it('parses a valid integer string', () => {
205
+ expect(parseLatestCount('5')).toBe(5)
206
+ })
207
+
208
+ it('clamps to 1 for values below minimum', () => {
209
+ expect(parseLatestCount('0')).toBe(1)
210
+ expect(parseLatestCount('-5')).toBe(1)
211
+ })
212
+
213
+ it('clamps to 100 for values above maximum', () => {
214
+ expect(parseLatestCount('200')).toBe(100)
215
+ })
216
+
217
+ it('returns 10 for non-numeric input', () => {
218
+ expect(parseLatestCount('abc')).toBe(10)
219
+ })
220
+ })
@@ -1,34 +1,22 @@
1
1
  import type { Context, Next } from 'hono'
2
+ import type { AppEnv } from '../types'
2
3
  import { publicProblem } from './problem-details'
3
-
4
- type PublicBindings = {
5
- PUBLIC_READ_RATE_LIMITER?: RateLimit
6
- PUBLIC_WRITE_RATE_LIMITER?: RateLimit
7
- }
4
+ import { getClientIp } from '../shared/request-utils'
8
5
 
9
6
  function isReadMethod(method: string): boolean {
10
7
  return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
11
8
  }
12
9
 
13
- function getClientIp(headers: Headers): string {
14
- return headers.get('cf-connecting-ip') ?? 'unknown'
15
- }
16
-
17
10
  export function publicRateLimitMiddleware() {
18
- return async (c: Context, next: Next): Promise<Response | void> => {
19
- const env = c.env as PublicBindings
11
+ return async (c: Context<AppEnv>, next: Next): Promise<Response | void> => {
20
12
  const readMethod = isReadMethod(c.req.method)
21
- const limiter = readMethod ? env.PUBLIC_READ_RATE_LIMITER : env.PUBLIC_WRITE_RATE_LIMITER
22
- if (!limiter) {
23
- await next()
24
- return
25
- }
13
+ const limiterName = readMethod ? ('publicApiRead' as const) : ('publicApiWrite' as const)
26
14
 
27
15
  const seed = c.req.param('seed') ?? 'no-seed'
28
- const key = `${getClientIp(c.req.raw.headers)}:${seed}:${readMethod ? 'read' : 'write'}`
29
- const { success } = await limiter.limit({ key })
16
+ const key = `${getClientIp(c.req)}:${seed}:${limiterName}`
17
+ const result = await c.get('rateLimiters').getLimiter(limiterName).checkLimit(key)
30
18
 
31
- if (!success) {
19
+ if (!result.isAllowed) {
32
20
  return publicProblem(c, {
33
21
  type: 'rate-limit-exceeded',
34
22
  title: 'Too Many Requests',
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { CloudflareRateLimiter } from './cloudflare-rate-limiter'
3
+
4
+ describe('CloudflareRateLimiter', () => {
5
+ it('returns isAllowed: true when the Cloudflare binding grants the request', async () => {
6
+ const mockBinding = { limit: vi.fn().mockResolvedValue({ success: true }) }
7
+ const limiter = new CloudflareRateLimiter(mockBinding as any)
8
+ const result = await limiter.checkLimit('192.168.1.1:login')
9
+ expect(result.isAllowed).toBe(true)
10
+ })
11
+
12
+ it('returns isAllowed: false when the Cloudflare binding blocks the request', async () => {
13
+ const mockBinding = { limit: vi.fn().mockResolvedValue({ success: false }) }
14
+ const limiter = new CloudflareRateLimiter(mockBinding as any)
15
+ const result = await limiter.checkLimit('192.168.1.1:login')
16
+ expect(result.isAllowed).toBe(false)
17
+ })
18
+
19
+ it('forwards the exact key to the binding so per-key accounting is correct', async () => {
20
+ const mockBinding = { limit: vi.fn().mockResolvedValue({ success: true }) }
21
+ const limiter = new CloudflareRateLimiter(mockBinding as any)
22
+ const key = '10.0.0.1:some-seed:publicApiRead'
23
+ await limiter.checkLimit(key)
24
+ expect(mockBinding.limit).toHaveBeenCalledWith({ key })
25
+ })
26
+ })
@@ -0,0 +1,11 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
3
+
4
+ export class CloudflareRateLimiter implements IRateLimiter {
5
+ constructor(private readonly binding: RateLimit) {}
6
+
7
+ async checkLimit(key: string): Promise<RateLimitResult> {
8
+ const { success } = await this.binding.limit({ key })
9
+ return { isAllowed: success }
10
+ }
11
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { InMemoryRateLimiter } from './in-memory-rate-limiter'
3
+
4
+ describe('InMemoryRateLimiter', () => {
5
+ it('allows requests up to the configured maximum hit count', async () => {
6
+ const limiter = new InMemoryRateLimiter(3)
7
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(true)
8
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(true)
9
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(true)
10
+ })
11
+
12
+ it('blocks the very next request after maxAllowedHits is reached', async () => {
13
+ const limiter = new InMemoryRateLimiter(2)
14
+ await limiter.checkLimit('key')
15
+ await limiter.checkLimit('key')
16
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(false)
17
+ })
18
+
19
+ it('tracks hit counts independently for each key', async () => {
20
+ const limiter = new InMemoryRateLimiter(1)
21
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(true)
22
+ expect((await limiter.checkLimit('ip-b')).isAllowed).toBe(true)
23
+ // Both keys exhausted independently
24
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(false)
25
+ expect((await limiter.checkLimit('ip-b')).isAllowed).toBe(false)
26
+ })
27
+
28
+ it('maxAllowedHits = 1 allows exactly one request before blocking', async () => {
29
+ const limiter = new InMemoryRateLimiter(1)
30
+ expect((await limiter.checkLimit('k')).isAllowed).toBe(true)
31
+ expect((await limiter.checkLimit('k')).isAllowed).toBe(false)
32
+ })
33
+ })
@@ -0,0 +1,13 @@
1
+ import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
2
+
3
+ export class InMemoryRateLimiter implements IRateLimiter {
4
+ private readonly hitCounts = new Map<string, number>()
5
+
6
+ constructor(private readonly maxAllowedHits: number) {}
7
+
8
+ async checkLimit(key: string): Promise<RateLimitResult> {
9
+ const currentHits = (this.hitCounts.get(key) ?? 0) + 1
10
+ this.hitCounts.set(key, currentHits)
11
+ return { isAllowed: currentHits <= this.maxAllowedHits }
12
+ }
13
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { NoOpRateLimiter } from './no-op-rate-limiter'
3
+
4
+ describe('NoOpRateLimiter', () => {
5
+ it('always returns isAllowed: true regardless of the key', async () => {
6
+ const limiter = new NoOpRateLimiter()
7
+ for (let i = 0; i < 100; i++) {
8
+ expect((await limiter.checkLimit('any-key')).isAllowed).toBe(true)
9
+ }
10
+ })
11
+
12
+ it('allows all keys without accumulating state', async () => {
13
+ const limiter = new NoOpRateLimiter()
14
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(true)
15
+ expect((await limiter.checkLimit('ip-b')).isAllowed).toBe(true)
16
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(true)
17
+ })
18
+ })
@@ -0,0 +1,7 @@
1
+ import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
2
+
3
+ export class NoOpRateLimiter implements IRateLimiter {
4
+ async checkLimit(_key: string): Promise<RateLimitResult> {
5
+ return { isAllowed: true }
6
+ }
7
+ }