@beechcms/api 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- 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 +80 -80
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +22 -10
- package/src/features/content/handlers/delete.ts +21 -10
- package/src/features/content/handlers/update.ts +21 -9
- package/src/features/draft/draft.handler.ts +51 -153
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- 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 +64 -176
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/index.ts +40 -8
- 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 +72 -5
- package/src/middleware.ts +15 -35
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +110 -166
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +59 -216
- 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/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- 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/automations.repository.d1.ts +146 -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.test.ts +76 -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/execution-context-scheduler.ts +9 -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/storage-utils.ts +3 -3
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +24 -3
- package/src/upload.ts +17 -9
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-CewtCjom.css +0 -1
- 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
|
@@ -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
|
+
})
|
package/src/search-utils.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Pure functions — zero Hono dependencies, importable from Vitest.
|
|
3
3
|
// v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
|
|
4
4
|
|
|
5
|
-
import type { Seed } from "@beechcms/core"
|
|
5
|
+
import type { Seed, SearchResultRow } from "@beechcms/core"
|
|
6
6
|
|
|
7
7
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
@@ -190,3 +190,20 @@ export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
|
190
190
|
data: {},
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Maps a repository-shaped SearchResultRow (camelCase) to the wire-format
|
|
196
|
+
* SearchResultItem returned by the /api/search route. Keeps the HTML
|
|
197
|
+
* stripping behaviour previously inlined inside mapFtsRow.
|
|
198
|
+
*/
|
|
199
|
+
export function mapSearchResultRow(row: SearchResultRow): SearchResultItem {
|
|
200
|
+
return {
|
|
201
|
+
id: row.entryId,
|
|
202
|
+
schema_slug: row.schemaSlug,
|
|
203
|
+
slug: row.slug,
|
|
204
|
+
status: row.status,
|
|
205
|
+
title: row.title ?? "",
|
|
206
|
+
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
207
|
+
data: {},
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/search.ts
CHANGED
|
@@ -4,69 +4,58 @@ import { Hono } from "hono"
|
|
|
4
4
|
import type { Env, Variables } from "./types"
|
|
5
5
|
import { authMiddleware } from "./middleware"
|
|
6
6
|
import {
|
|
7
|
-
buildFtsQuery,
|
|
8
7
|
encodeCursor,
|
|
9
|
-
|
|
10
|
-
type FtsRow,
|
|
8
|
+
mapSearchResultRow,
|
|
11
9
|
type SearchResponse,
|
|
12
10
|
} from "./search-utils"
|
|
13
11
|
|
|
14
12
|
export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
15
13
|
|
|
16
|
-
searchRouter.use("*",
|
|
17
|
-
return authMiddleware(c.env.JWT_SECRET, {
|
|
18
|
-
issuer: c.env.JWT_ISSUER,
|
|
19
|
-
audience: c.env.JWT_AUDIENCE,
|
|
20
|
-
})(c, next)
|
|
21
|
-
})
|
|
14
|
+
searchRouter.use("*", authMiddleware())
|
|
22
15
|
|
|
23
16
|
// GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
|
|
24
17
|
searchRouter.get("/", async (c) => {
|
|
25
|
-
const
|
|
18
|
+
const queryText = c.req.query("q")?.trim() ?? ""
|
|
26
19
|
const schemaSlug = c.req.query("schema_slug") ?? null
|
|
27
20
|
const status = c.req.query("status") ?? null
|
|
28
21
|
const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
|
|
29
22
|
const limit = Math.min(Math.max(rawLimit, 1), 50)
|
|
30
23
|
const cursor = c.req.query("cursor") ?? null
|
|
31
24
|
|
|
32
|
-
if (
|
|
25
|
+
if (queryText.length < 2) {
|
|
33
26
|
return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
const seeds =
|
|
29
|
+
const seeds = c.get('seedRegistry').all()
|
|
30
|
+
const searchRepository = c.get('searchRepository')
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
throw e
|
|
32
|
+
const queryOptions = {
|
|
33
|
+
queryText,
|
|
34
|
+
schemaSlug,
|
|
35
|
+
statusFilter: status,
|
|
36
|
+
limit,
|
|
37
|
+
cursor,
|
|
46
38
|
}
|
|
39
|
+
const countOptions = { queryText, schemaSlug, statusFilter: status }
|
|
47
40
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
|
|
52
|
-
c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
|
|
41
|
+
const [rawRows, countResult] = await Promise.all([
|
|
42
|
+
searchRepository.search(queryOptions, seeds),
|
|
43
|
+
searchRepository.count(countOptions, seeds),
|
|
53
44
|
])
|
|
54
45
|
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const hasMore = rows.length > limit
|
|
59
|
-
const pageRows = hasMore ? rows.slice(0, limit) : rows
|
|
46
|
+
const hasMore = rawRows.length > limit
|
|
47
|
+
const pageRows = hasMore ? rawRows.slice(0, limit) : rawRows
|
|
60
48
|
|
|
61
|
-
const
|
|
62
|
-
|
|
49
|
+
const lastRow = pageRows.at(-1)
|
|
50
|
+
const nextCursor = hasMore && lastRow
|
|
51
|
+
? encodeCursor(lastRow.rank, lastRow.entryId)
|
|
63
52
|
: null
|
|
64
53
|
|
|
65
54
|
if (pageRows.length === 0) {
|
|
66
|
-
return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
|
|
55
|
+
return c.json({ items: [], nextCursor: null, total: countResult.total } satisfies SearchResponse)
|
|
67
56
|
}
|
|
68
57
|
|
|
69
|
-
const items = pageRows.map(
|
|
58
|
+
const items = pageRows.map(mapSearchResultRow)
|
|
70
59
|
|
|
71
|
-
return c.json({ items, nextCursor, total } satisfies SearchResponse)
|
|
60
|
+
return c.json({ items, nextCursor, total: countResult.total } satisfies SearchResponse)
|
|
72
61
|
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { applyPrivacy, applyVisibility, PrivacyPolicyError } from './apply-policies'
|
|
3
|
+
import type { Seed } from '@beechcms/core'
|
|
4
|
+
|
|
5
|
+
function makeSeed(branches: any[]): Seed {
|
|
6
|
+
return { slug: 'test', displayNameAlias: 'title', branches } as unknown as Seed
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// ─── applyPrivacy ─────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe('applyPrivacy', () => {
|
|
12
|
+
it('passes through fields with no privacy policy (default: public)', async () => {
|
|
13
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
|
|
14
|
+
const result = await applyPrivacy({ title: 'Hello' }, seed)
|
|
15
|
+
expect(result.title).toBe('Hello')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('hashes the value when branch privacy is "hash"', async () => {
|
|
19
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'email', type: 'text', policies: { privacy: 'hash' } }])
|
|
20
|
+
const result = await applyPrivacy({ email: 'user@test.com' }, seed)
|
|
21
|
+
// sha256 produces a 64-char hex string
|
|
22
|
+
expect(typeof result.email).toBe('string')
|
|
23
|
+
expect((result.email as string).length).toBe(64)
|
|
24
|
+
expect(result.email).not.toBe('user@test.com')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('leaves null/undefined values unhashed even when privacy is "hash"', async () => {
|
|
28
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'email', type: 'text', policies: { privacy: 'hash' } }])
|
|
29
|
+
const result = await applyPrivacy({ email: null }, seed)
|
|
30
|
+
expect(result.email).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('throws PrivacyPolicyError for "encrypt" privacy (not yet implemented)', async () => {
|
|
34
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'secret', type: 'text', policies: { privacy: 'encrypt' } }])
|
|
35
|
+
await expect(applyPrivacy({ secret: 'value' }, seed)).rejects.toBeInstanceOf(PrivacyPolicyError)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('passes through fields not present in the seed branches unchanged', async () => {
|
|
39
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
|
|
40
|
+
const result = await applyPrivacy({ title: 'Hi', extraField: 'extra' }, seed)
|
|
41
|
+
expect(result.extraField).toBe('extra')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ─── applyVisibility ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe('applyVisibility', () => {
|
|
48
|
+
it('includes fields with default (visible) visibility', () => {
|
|
49
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
|
|
50
|
+
const result = applyVisibility({ title: 'Hello' }, seed)
|
|
51
|
+
expect(result.title).toBe('Hello')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('omits fields with visibility "hidden"', () => {
|
|
55
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'internal', type: 'text', policies: { visibility: 'hidden' } }])
|
|
56
|
+
const result = applyVisibility({ internal: 'secret' }, seed)
|
|
57
|
+
expect(result).not.toHaveProperty('internal')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('masks non-empty string fields with visibility "masked"', () => {
|
|
61
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'password', type: 'text', policies: { visibility: 'masked' } }])
|
|
62
|
+
const result = applyVisibility({ password: 'secret123' }, seed)
|
|
63
|
+
expect(result.password).toBe('••••••••')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns null for empty string fields with visibility "masked"', () => {
|
|
67
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'password', type: 'text', policies: { visibility: 'masked' } }])
|
|
68
|
+
const result = applyVisibility({ password: '' }, seed)
|
|
69
|
+
expect(result.password).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('passes through fields not present in the seed branches', () => {
|
|
73
|
+
const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
|
|
74
|
+
const result = applyVisibility({ title: 'Hi', extraField: 'pass' }, seed)
|
|
75
|
+
expect(result.extraField).toBe('pass')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAutomationRepository,
|
|
3
|
+
Automation,
|
|
4
|
+
AutomationAction,
|
|
5
|
+
AutomationTrigger,
|
|
6
|
+
AutomationTriggerEvent,
|
|
7
|
+
CreateAutomationInput,
|
|
8
|
+
UpdateAutomationInput,
|
|
9
|
+
WhenNode,
|
|
10
|
+
} from '@beechcms/core'
|
|
11
|
+
|
|
12
|
+
interface AutomationRow {
|
|
13
|
+
id: string
|
|
14
|
+
seed_slug: string
|
|
15
|
+
name: string
|
|
16
|
+
enabled: number
|
|
17
|
+
triggers: string
|
|
18
|
+
trigger_conditions: string | null
|
|
19
|
+
actions: string
|
|
20
|
+
created_at: number
|
|
21
|
+
updated_at: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class D1AutomationRepository implements IAutomationRepository {
|
|
25
|
+
constructor(private readonly db: D1Database) {}
|
|
26
|
+
|
|
27
|
+
async findActive(seedSlug: string, event: AutomationTriggerEvent): Promise<Automation[]> {
|
|
28
|
+
const result = seedSlug === '*'
|
|
29
|
+
? await this.db
|
|
30
|
+
.prepare(
|
|
31
|
+
`SELECT DISTINCT a.* FROM automations a, json_each(a.triggers) t
|
|
32
|
+
WHERE a.enabled = 1 AND json_extract(t.value, '$.event') = ?`,
|
|
33
|
+
)
|
|
34
|
+
.bind(event)
|
|
35
|
+
.all<AutomationRow>()
|
|
36
|
+
: await this.db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT DISTINCT a.* FROM automations a, json_each(a.triggers) t
|
|
39
|
+
WHERE a.seed_slug = ? AND a.enabled = 1 AND json_extract(t.value, '$.event') = ?`,
|
|
40
|
+
)
|
|
41
|
+
.bind(seedSlug, event)
|
|
42
|
+
.all<AutomationRow>()
|
|
43
|
+
return (result.results ?? []).map(rowToAutomation)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async list(seedSlug: string): Promise<Automation[]> {
|
|
47
|
+
const result = await this.db
|
|
48
|
+
.prepare(`SELECT * FROM automations WHERE seed_slug = ? ORDER BY created_at DESC`)
|
|
49
|
+
.bind(seedSlug)
|
|
50
|
+
.all<AutomationRow>()
|
|
51
|
+
return (result.results ?? []).map(rowToAutomation)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async findById(id: string): Promise<Automation | null> {
|
|
55
|
+
const row = await this.db
|
|
56
|
+
.prepare(`SELECT * FROM automations WHERE id = ?`)
|
|
57
|
+
.bind(id)
|
|
58
|
+
.first<AutomationRow>()
|
|
59
|
+
return row ? rowToAutomation(row) : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async create(input: CreateAutomationInput): Promise<string> {
|
|
63
|
+
const id = crypto.randomUUID()
|
|
64
|
+
const now = Math.floor(Date.now() / 1000)
|
|
65
|
+
await this.db
|
|
66
|
+
.prepare(
|
|
67
|
+
`INSERT INTO automations
|
|
68
|
+
(id, seed_slug, name, enabled, triggers, trigger_conditions, actions, created_at, updated_at)
|
|
69
|
+
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)`,
|
|
70
|
+
)
|
|
71
|
+
.bind(
|
|
72
|
+
id,
|
|
73
|
+
input.seed_slug,
|
|
74
|
+
input.name,
|
|
75
|
+
JSON.stringify(input.triggers),
|
|
76
|
+
input.trigger_conditions ? JSON.stringify(input.trigger_conditions) : null,
|
|
77
|
+
JSON.stringify(input.actions),
|
|
78
|
+
now,
|
|
79
|
+
now,
|
|
80
|
+
)
|
|
81
|
+
.run()
|
|
82
|
+
return id
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async update(id: string, input: UpdateAutomationInput): Promise<void> {
|
|
86
|
+
const now = Math.floor(Date.now() / 1000)
|
|
87
|
+
const fields: string[] = []
|
|
88
|
+
const values: unknown[] = []
|
|
89
|
+
|
|
90
|
+
const map: Record<string, unknown> = {
|
|
91
|
+
seed_slug: input.seed_slug,
|
|
92
|
+
name: input.name,
|
|
93
|
+
triggers: input.triggers !== undefined ? JSON.stringify(input.triggers) : undefined,
|
|
94
|
+
trigger_conditions:
|
|
95
|
+
input.trigger_conditions !== undefined
|
|
96
|
+
? input.trigger_conditions === null
|
|
97
|
+
? null
|
|
98
|
+
: JSON.stringify(input.trigger_conditions)
|
|
99
|
+
: undefined,
|
|
100
|
+
actions: input.actions !== undefined ? JSON.stringify(input.actions) : undefined,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const [column, value] of Object.entries(map)) {
|
|
104
|
+
if (value !== undefined) {
|
|
105
|
+
fields.push(`${column} = ?`)
|
|
106
|
+
values.push(value)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (fields.length === 0) return
|
|
111
|
+
|
|
112
|
+
values.push(now, id)
|
|
113
|
+
await this.db
|
|
114
|
+
.prepare(`UPDATE automations SET ${fields.join(', ')}, updated_at = ? WHERE id = ?`)
|
|
115
|
+
.bind(...values)
|
|
116
|
+
.run()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async toggle(id: string, enabled: boolean): Promise<void> {
|
|
120
|
+
const now = Math.floor(Date.now() / 1000)
|
|
121
|
+
await this.db
|
|
122
|
+
.prepare(`UPDATE automations SET enabled = ?, updated_at = ? WHERE id = ?`)
|
|
123
|
+
.bind(enabled ? 1 : 0, now, id)
|
|
124
|
+
.run()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async delete(id: string): Promise<void> {
|
|
128
|
+
await this.db.prepare(`DELETE FROM automations WHERE id = ?`).bind(id).run()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rowToAutomation(row: AutomationRow): Automation {
|
|
133
|
+
return {
|
|
134
|
+
id: row.id,
|
|
135
|
+
seed_slug: row.seed_slug,
|
|
136
|
+
name: row.name,
|
|
137
|
+
enabled: row.enabled === 1,
|
|
138
|
+
triggers: JSON.parse(row.triggers) as AutomationTrigger[],
|
|
139
|
+
trigger_conditions: row.trigger_conditions
|
|
140
|
+
? (JSON.parse(row.trigger_conditions) as WhenNode)
|
|
141
|
+
: null,
|
|
142
|
+
actions: JSON.parse(row.actions) as AutomationAction[],
|
|
143
|
+
created_at: row.created_at,
|
|
144
|
+
updated_at: row.updated_at,
|
|
145
|
+
}
|
|
146
|
+
}
|