@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.
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/index.html +1 -1
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +70 -78
- package/src/features/content/handlers/create.ts +14 -10
- package/src/features/content/handlers/delete.ts +13 -9
- package/src/features/content/handlers/update.ts +13 -9
- package/src/features/draft/draft.handler.ts +23 -12
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +62 -175
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +41 -5
- package/src/middleware.ts +15 -35
- package/src/public/public-add.ts +5 -15
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +3 -3
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +20 -3
- package/src/upload.ts +14 -7
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- 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 {
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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(
|
|
39
|
-
return async (c: Context
|
|
40
|
-
const
|
|
41
|
-
if (!
|
|
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 =
|
|
33
|
+
const token = authHeader.slice(7)
|
|
46
34
|
if (!token) {
|
|
47
35
|
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
48
36
|
}
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
}
|
package/src/public/public-add.ts
CHANGED
|
@@ -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 ||
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
17
|
-
const available =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
29
|
-
const
|
|
16
|
+
const key = `${getClientIp(c.req)}:${seed}:${limiterName}`
|
|
17
|
+
const result = await c.get('rateLimiters').getLimiter(limiterName).checkLimit(key)
|
|
30
18
|
|
|
31
|
-
if (!
|
|
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
|
+
})
|