@beechcms/api 0.4.0-preview.9 → 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/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +10 -10
- 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 +74 -91
- package/src/auth/refresh.ts +5 -127
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +339 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +157 -0
- package/src/features/content/handlers/delete.ts +80 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +211 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +283 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +101 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +82 -88
- package/src/features/password-reset/reset.ts +92 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +128 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +301 -249
- package/src/features/setup/index.ts +91 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +430 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- 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 +60 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +47 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +156 -183
- package/src/public/public-edit.ts +159 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +30 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- 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 +209 -192
- package/src/search.ts +61 -72
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/content.repository.d1.ts +382 -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/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/idempotency.repository.d1.ts +56 -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 +64 -0
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +63 -36
- package/src/upload.ts +186 -335
- package/src/widget.ts +208 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -1,42 +1,30 @@
|
|
|
1
|
-
import type { Context, Next } from 'hono'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (!success) {
|
|
32
|
-
return publicProblem(c, {
|
|
33
|
-
type: 'rate-limit-exceeded',
|
|
34
|
-
title: 'Too Many Requests',
|
|
35
|
-
status: 429,
|
|
36
|
-
detail: 'Too many requests',
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
await next()
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
import type { Context, Next } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types'
|
|
3
|
+
import { publicProblem } from './problem-details'
|
|
4
|
+
import { getClientIp } from '../shared/request-utils'
|
|
5
|
+
|
|
6
|
+
function isReadMethod(method: string): boolean {
|
|
7
|
+
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function publicRateLimitMiddleware() {
|
|
11
|
+
return async (c: Context<AppEnv>, next: Next): Promise<Response | void> => {
|
|
12
|
+
const readMethod = isReadMethod(c.req.method)
|
|
13
|
+
const limiterName = readMethod ? ('publicApiRead' as const) : ('publicApiWrite' as const)
|
|
14
|
+
|
|
15
|
+
const seed = c.req.param('seed') ?? 'no-seed'
|
|
16
|
+
const key = `${getClientIp(c.req)}:${seed}:${limiterName}`
|
|
17
|
+
const result = await c.get('rateLimiters').getLimiter(limiterName).checkLimit(key)
|
|
18
|
+
|
|
19
|
+
if (!result.isAllowed) {
|
|
20
|
+
return publicProblem(c, {
|
|
21
|
+
type: 'rate-limit-exceeded',
|
|
22
|
+
title: 'Too Many Requests',
|
|
23
|
+
status: 429,
|
|
24
|
+
detail: 'Too many requests',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await next()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helper meta per risposta lista Public API.
|
|
3
|
-
*/
|
|
4
|
-
export function buildPublicListMeta(input: {
|
|
5
|
-
total: number
|
|
6
|
-
page: number
|
|
7
|
-
limit: number
|
|
8
|
-
returned: number
|
|
9
|
-
seed: string
|
|
10
|
-
}) {
|
|
11
|
-
return {
|
|
12
|
-
total: input.total,
|
|
13
|
-
page: input.page,
|
|
14
|
-
limit: input.limit,
|
|
15
|
-
returned: input.returned,
|
|
16
|
-
seed: input.seed,
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Helper meta per risposta singolo elemento Public API.
|
|
22
|
-
*/
|
|
23
|
-
export function buildPublicSingleMeta(seed: string) {
|
|
24
|
-
return { seed }
|
|
25
|
-
}
|
|
26
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Helper meta per risposta lista Public API.
|
|
3
|
+
*/
|
|
4
|
+
export function buildPublicListMeta(input: {
|
|
5
|
+
total: number
|
|
6
|
+
page: number
|
|
7
|
+
limit: number
|
|
8
|
+
returned: number
|
|
9
|
+
seed: string
|
|
10
|
+
}) {
|
|
11
|
+
return {
|
|
12
|
+
total: input.total,
|
|
13
|
+
page: input.page,
|
|
14
|
+
limit: input.limit,
|
|
15
|
+
returned: input.returned,
|
|
16
|
+
seed: input.seed,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Helper meta per risposta singolo elemento Public API.
|
|
22
|
+
*/
|
|
23
|
+
export function buildPublicSingleMeta(seed: string) {
|
|
24
|
+
return { seed }
|
|
25
|
+
}
|
|
26
|
+
|
package/src/public/sanitize.ts
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
import { validateAndSanitizeSeedPayload } from '@beechcms/core'
|
|
2
|
-
import type { Seed, ValidationDetail } from '@beechcms/core'
|
|
3
|
-
|
|
4
|
-
type PublicSanitizeSuccess = {
|
|
5
|
-
ok: true
|
|
6
|
-
data: Record<string, unknown>
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
type PublicSanitizeFailure = {
|
|
10
|
-
ok: false
|
|
11
|
-
status: 400 | 422
|
|
12
|
-
code: 'validation_failed' | 'dangerous_content'
|
|
13
|
-
message: string
|
|
14
|
-
details?: ValidationDetail[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type PublicSanitizeResult = PublicSanitizeSuccess | PublicSanitizeFailure
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
*/
|
|
22
|
-
export function sanitizePublicPayload(
|
|
23
|
-
seed: Seed,
|
|
24
|
-
payload: Record<string, unknown>,
|
|
25
|
-
options: {
|
|
26
|
-
allowNull?: boolean
|
|
27
|
-
operation?: 'create' | 'update'
|
|
28
|
-
requireAtLeastOneValidField?: boolean
|
|
29
|
-
enforceRequiredFields?: boolean
|
|
30
|
-
} = {}
|
|
31
|
-
): PublicSanitizeResult {
|
|
32
|
-
const operation = options.operation ?? 'create'
|
|
33
|
-
const result = validateAndSanitizeSeedPayload(seed, payload, {
|
|
34
|
-
allowNull: options.allowNull ?? false,
|
|
35
|
-
operation,
|
|
36
|
-
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
37
|
-
enforceRequiredFields: options.enforceRequiredFields ?? true,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
if (result.dangerousFields.length > 0) {
|
|
41
|
-
const field = result.dangerousFields[0]
|
|
42
|
-
return {
|
|
43
|
-
ok: false,
|
|
44
|
-
status: 422,
|
|
45
|
-
code: 'dangerous_content',
|
|
46
|
-
message: `Content rejected: dangerous markup detected in field '${field}'`,
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (result.details.length > 0) {
|
|
51
|
-
return {
|
|
52
|
-
ok: false,
|
|
53
|
-
status: 400,
|
|
54
|
-
code: 'validation_failed',
|
|
55
|
-
message: 'Validation failed',
|
|
56
|
-
details: result.details,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
ok: true,
|
|
62
|
-
data: result.data,
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
1
|
+
import { validateAndSanitizeSeedPayload } from '@beechcms/core'
|
|
2
|
+
import type { Seed, ValidationDetail } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
type PublicSanitizeSuccess = {
|
|
5
|
+
ok: true
|
|
6
|
+
data: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type PublicSanitizeFailure = {
|
|
10
|
+
ok: false
|
|
11
|
+
status: 400 | 422
|
|
12
|
+
code: 'validation_failed' | 'dangerous_content'
|
|
13
|
+
message: string
|
|
14
|
+
details?: ValidationDetail[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PublicSanitizeResult = PublicSanitizeSuccess | PublicSanitizeFailure
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Public API Adapter: uses the core foundation and maps errors into the sprint format.
|
|
21
|
+
*/
|
|
22
|
+
export function sanitizePublicPayload(
|
|
23
|
+
seed: Seed,
|
|
24
|
+
payload: Record<string, unknown>,
|
|
25
|
+
options: {
|
|
26
|
+
allowNull?: boolean
|
|
27
|
+
operation?: 'create' | 'update'
|
|
28
|
+
requireAtLeastOneValidField?: boolean
|
|
29
|
+
enforceRequiredFields?: boolean
|
|
30
|
+
} = {}
|
|
31
|
+
): PublicSanitizeResult {
|
|
32
|
+
const operation = options.operation ?? 'create'
|
|
33
|
+
const result = validateAndSanitizeSeedPayload(seed, payload, {
|
|
34
|
+
allowNull: options.allowNull ?? false,
|
|
35
|
+
operation,
|
|
36
|
+
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
37
|
+
enforceRequiredFields: options.enforceRequiredFields ?? true,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (result.dangerousFields.length > 0) {
|
|
41
|
+
const field = result.dangerousFields[0]
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 422,
|
|
45
|
+
code: 'dangerous_content',
|
|
46
|
+
message: `Content rejected: dangerous markup detected in field '${field}'`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (result.details.length > 0) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
status: 400,
|
|
54
|
+
code: 'validation_failed',
|
|
55
|
+
message: 'Validation failed',
|
|
56
|
+
details: result.details,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
data: result.data,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
package/src/public/slug-utils.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { slugify, generateEntrySlug } from '@beechcms/core'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*/
|
|
7
|
-
export { slugify }
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*/
|
|
13
|
-
export { generateEntrySlug }
|
|
14
|
-
|
|
1
|
+
import { slugify, generateEntrySlug } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts a string into a URL-safe slug.
|
|
5
|
+
* Logic moved to @beechcms/core for consistency between Dashboard and API.
|
|
6
|
+
*/
|
|
7
|
+
export { slugify }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generates a slug from a title/name or a UUID-like fallback.
|
|
11
|
+
* Logic moved to @beechcms/core for consistency between Dashboard and API.
|
|
12
|
+
*/
|
|
13
|
+
export { generateEntrySlug }
|
|
14
|
+
|
|
@@ -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,207 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { encodeCursor, decodeCursor, buildFtsQuery, mapFtsRow } from './search-utils'
|
|
3
|
+
import type { Seed } from '@beechcms/core'
|
|
4
|
+
|
|
5
|
+
const TEXT_SEED = {
|
|
6
|
+
slug: 'articoli',
|
|
7
|
+
displayNameAlias: 'title',
|
|
8
|
+
branches: [
|
|
9
|
+
{ id: 'br_01', alias: 'title', type: 'text', policies: { search: true } },
|
|
10
|
+
],
|
|
11
|
+
} as unknown as Seed
|
|
12
|
+
|
|
13
|
+
const SECOND_SEED = {
|
|
14
|
+
slug: 'team',
|
|
15
|
+
displayNameAlias: 'name',
|
|
16
|
+
branches: [
|
|
17
|
+
{ id: 'br_01', alias: 'name', type: 'text', policies: { search: true } },
|
|
18
|
+
],
|
|
19
|
+
} as unknown as Seed
|
|
20
|
+
|
|
21
|
+
const NO_FTS_SEED = {
|
|
22
|
+
slug: 'prodotti',
|
|
23
|
+
displayNameAlias: 'nome',
|
|
24
|
+
branches: [
|
|
25
|
+
{ id: 'br_01', alias: 'price', type: 'number' },
|
|
26
|
+
],
|
|
27
|
+
} as unknown as Seed
|
|
28
|
+
|
|
29
|
+
// ─── encodeCursor / decodeCursor ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
describe('encodeCursor / decodeCursor', () => {
|
|
32
|
+
it('roundtrip preserves rank and entryId', () => {
|
|
33
|
+
const cursor = encodeCursor(-1.23456, 'entry-abc')
|
|
34
|
+
const decoded = decodeCursor(cursor)
|
|
35
|
+
expect(decoded?.rank).toBeCloseTo(-1.23456)
|
|
36
|
+
expect(decoded?.entryId).toBe('entry-abc')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('entryId without colons roundtrips correctly (UUID-style IDs)', () => {
|
|
40
|
+
const entryId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
|
41
|
+
const cursor = encodeCursor(-3.7, entryId)
|
|
42
|
+
const decoded = decodeCursor(cursor)
|
|
43
|
+
expect(decoded?.rank).toBeCloseTo(-3.7)
|
|
44
|
+
expect(decoded?.entryId).toBe(entryId)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null for invalid base64', () => {
|
|
48
|
+
expect(decodeCursor('!!!not-base64!!!')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns null when decoded string has no colon separator', () => {
|
|
52
|
+
expect(decodeCursor(btoa('noseparator'))).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ─── buildFtsQuery ───────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('buildFtsQuery', () => {
|
|
59
|
+
it('returns empty-result query when no seed has a searchable FTS branch', () => {
|
|
60
|
+
const result = buildFtsQuery(
|
|
61
|
+
{ q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: null },
|
|
62
|
+
[NO_FTS_SEED],
|
|
63
|
+
)
|
|
64
|
+
expect(result.sql).toContain('WHERE 1=0')
|
|
65
|
+
expect(result.binds).toHaveLength(0)
|
|
66
|
+
expect(result.countSql).toContain('SELECT 0 as total')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('throws EMPTY_QUERY when all terms are stripped or too short (single chars)', () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
buildFtsQuery({ q: 'a b', schemaSlug: null, status: null, limit: 20, cursor: null }, [TEXT_SEED]),
|
|
72
|
+
).toThrow('EMPTY_QUERY')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('generates a query referencing the seed FTS and content tables', () => {
|
|
76
|
+
const result = buildFtsQuery(
|
|
77
|
+
{ q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: null },
|
|
78
|
+
[TEXT_SEED],
|
|
79
|
+
)
|
|
80
|
+
expect(result.sql).toContain('fts_articoli')
|
|
81
|
+
expect(result.sql).toContain('content_articoli')
|
|
82
|
+
expect(result.sql).toContain('LIMIT ?')
|
|
83
|
+
expect(result.binds.at(-1)).toBe(21) // limit + 1 for has-more detection
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('UNION ALLs multiple seeds when no schemaSlug filter is set', () => {
|
|
87
|
+
const result = buildFtsQuery(
|
|
88
|
+
{ q: 'test', schemaSlug: null, status: null, limit: 10, cursor: null },
|
|
89
|
+
[TEXT_SEED, SECOND_SEED],
|
|
90
|
+
)
|
|
91
|
+
expect(result.sql).toContain('UNION ALL')
|
|
92
|
+
expect(result.sql).toContain('fts_articoli')
|
|
93
|
+
expect(result.sql).toContain('fts_team')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('limits query to the requested schemaSlug when provided', () => {
|
|
97
|
+
const result = buildFtsQuery(
|
|
98
|
+
{ q: 'test', schemaSlug: 'articoli', status: null, limit: 20, cursor: null },
|
|
99
|
+
[TEXT_SEED, SECOND_SEED],
|
|
100
|
+
)
|
|
101
|
+
expect(result.sql).toContain('fts_articoli')
|
|
102
|
+
expect(result.sql).not.toContain('fts_team')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('adds status filter to WHERE clause and bind values', () => {
|
|
106
|
+
const result = buildFtsQuery(
|
|
107
|
+
{ q: 'hello', schemaSlug: null, status: 'published', limit: 20, cursor: null },
|
|
108
|
+
[TEXT_SEED],
|
|
109
|
+
)
|
|
110
|
+
expect(result.sql).toContain('ce.status = ?')
|
|
111
|
+
expect(result.binds).toContain('published')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('adds cursor-based pagination condition when cursor is valid', () => {
|
|
115
|
+
const cursor = encodeCursor(-1.5, 'entry-123')
|
|
116
|
+
const result = buildFtsQuery(
|
|
117
|
+
{ q: 'hello', schemaSlug: null, status: null, limit: 20, cursor },
|
|
118
|
+
[TEXT_SEED],
|
|
119
|
+
)
|
|
120
|
+
expect(result.sql).toContain('bm25')
|
|
121
|
+
expect(result.binds).toContain('entry-123')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('ignores an invalid cursor and produces no pagination condition', () => {
|
|
125
|
+
const result = buildFtsQuery(
|
|
126
|
+
{ q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: 'bad-cursor' },
|
|
127
|
+
[TEXT_SEED],
|
|
128
|
+
)
|
|
129
|
+
expect(result.binds).not.toContain('entry-123')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('countSql wraps each seed count in a SUM', () => {
|
|
133
|
+
const result = buildFtsQuery(
|
|
134
|
+
{ q: 'test', schemaSlug: null, status: null, limit: 10, cursor: null },
|
|
135
|
+
[TEXT_SEED, SECOND_SEED],
|
|
136
|
+
)
|
|
137
|
+
expect(result.countSql).toContain('SUM')
|
|
138
|
+
expect(result.countSql).toContain('fts_articoli')
|
|
139
|
+
expect(result.countSql).toContain('fts_team')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('count binds do not include the limit+1 sentinel', () => {
|
|
143
|
+
const result = buildFtsQuery(
|
|
144
|
+
{ q: 'hello', schemaSlug: null, status: null, limit: 5, cursor: null },
|
|
145
|
+
[TEXT_SEED],
|
|
146
|
+
)
|
|
147
|
+
expect(result.countBinds).not.toContain(6) // limit + 1 must not appear in count binds
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('single-character terms (length < 2) are filtered out', () => {
|
|
151
|
+
// 'a b c' — single-char terms discarded; result depends on remaining terms
|
|
152
|
+
expect(() =>
|
|
153
|
+
buildFtsQuery({ q: 'a b c', schemaSlug: null, status: null, limit: 20, cursor: null }, [TEXT_SEED]),
|
|
154
|
+
).toThrow('EMPTY_QUERY')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('numeric terms are quoted without prefix expansion', () => {
|
|
158
|
+
const result = buildFtsQuery(
|
|
159
|
+
{ q: '2024', schemaSlug: null, status: null, limit: 20, cursor: null },
|
|
160
|
+
[TEXT_SEED],
|
|
161
|
+
)
|
|
162
|
+
expect(result.binds[0]).toContain('"2024"')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ─── mapFtsRow ───────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe('mapFtsRow', () => {
|
|
169
|
+
it('maps all FtsRow fields to SearchResultItem', () => {
|
|
170
|
+
const row = {
|
|
171
|
+
entry_id: 'e1', schema_slug: 'articoli', slug: 'my-post',
|
|
172
|
+
status: 'published', title: 'My Post', excerpt: 'A snippet', rank: -1,
|
|
173
|
+
}
|
|
174
|
+
const result = mapFtsRow(row)
|
|
175
|
+
expect(result.id).toBe('e1')
|
|
176
|
+
expect(result.schema_slug).toBe('articoli')
|
|
177
|
+
expect(result.slug).toBe('my-post')
|
|
178
|
+
expect(result.status).toBe('published')
|
|
179
|
+
expect(result.title).toBe('My Post')
|
|
180
|
+
expect(result.excerpt).toBe('A snippet')
|
|
181
|
+
expect(result.data).toEqual({})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('strips HTML tags from excerpt but preserves <mark> and </mark>', () => {
|
|
185
|
+
const row = {
|
|
186
|
+
entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
|
|
187
|
+
title: null, excerpt: '<p>A <mark>word</mark> here</p>', rank: 0,
|
|
188
|
+
}
|
|
189
|
+
expect(mapFtsRow(row).excerpt).toBe('A <mark>word</mark> here')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('collapses multiple whitespace characters in excerpt', () => {
|
|
193
|
+
const row = {
|
|
194
|
+
entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
|
|
195
|
+
title: null, excerpt: '<p> lots of space </p>', rank: 0,
|
|
196
|
+
}
|
|
197
|
+
expect(mapFtsRow(row).excerpt).toBe('lots of space')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('returns empty string for null title', () => {
|
|
201
|
+
const row = {
|
|
202
|
+
entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
|
|
203
|
+
title: null, excerpt: '', rank: 0,
|
|
204
|
+
}
|
|
205
|
+
expect(mapFtsRow(row).title).toBe('')
|
|
206
|
+
})
|
|
207
|
+
})
|