@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.
Files changed (138) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/auth/bcrypt-hash-provider.ts +20 -0
  8. package/src/auth/constants.ts +3 -3
  9. package/src/auth/generate-refresh-token.test.ts +19 -0
  10. package/src/auth/hash-provider.test.ts +46 -0
  11. package/src/auth/in-memory-hash-provider.ts +13 -0
  12. package/src/auth/jose-token-service.ts +55 -0
  13. package/src/auth/login.test.ts +92 -0
  14. package/src/auth/login.ts +15 -32
  15. package/src/auth/refresh.ts +0 -122
  16. package/src/auth/static-token-service.ts +18 -0
  17. package/src/auth/token-service.test.ts +82 -0
  18. package/src/factory.ts +80 -80
  19. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  20. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  21. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  22. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  23. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  24. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  25. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  26. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  27. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  28. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  29. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  30. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  31. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  32. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  33. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  34. package/src/features/automations/action-executors/index.ts +33 -0
  35. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  36. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  37. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  38. package/src/features/automations/automation-runner.ts +81 -0
  39. package/src/features/automations/automation-runner.utils.ts +43 -0
  40. package/src/features/automations/automations.handler.ts +193 -0
  41. package/src/features/automations/automations.schema.ts +160 -0
  42. package/src/features/automations/context-resolver.ts +148 -0
  43. package/src/features/automations/cron-runner.ts +136 -0
  44. package/src/features/automations/cron-runner.utils.ts +40 -0
  45. package/src/features/automations/filter-translation.ts +42 -0
  46. package/src/features/automations/index.ts +12 -0
  47. package/src/features/automations/template-grammar.ts +241 -0
  48. package/src/features/automations/var-access-resolver.ts +136 -0
  49. package/src/features/automations/when-evaluator.ts +83 -0
  50. package/src/features/automations/when-pushdown.ts +53 -0
  51. package/src/features/content/handlers/create.ts +22 -10
  52. package/src/features/content/handlers/delete.ts +21 -10
  53. package/src/features/content/handlers/update.ts +21 -9
  54. package/src/features/draft/draft.handler.ts +51 -153
  55. package/src/features/draft/draft.middleware.ts +62 -0
  56. package/src/features/email/email.service.ts +13 -0
  57. package/src/features/email/email.types.ts +10 -0
  58. package/src/features/email/index.ts +2 -1
  59. package/src/features/email/templates/automation-mail.ts +15 -0
  60. package/src/features/notifications/notifications.handler.ts +25 -54
  61. package/src/features/password-reset/request.ts +17 -41
  62. package/src/features/password-reset/reset.ts +18 -54
  63. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  64. package/src/features/schema/schema.handler.ts +1 -1
  65. package/src/features/settings/settings.handler.ts +64 -176
  66. package/src/features/setup/index.ts +12 -17
  67. package/src/features/stats/stats.handler.ts +110 -138
  68. package/src/index.ts +40 -8
  69. package/src/middleware/auth-providers.middleware.ts +32 -0
  70. package/src/middleware/observability.middleware.ts +52 -0
  71. package/src/middleware/rate-limit.middleware.ts +41 -0
  72. package/src/middleware/repository.middleware.ts +72 -5
  73. package/src/middleware.ts +15 -35
  74. package/src/public/cache-utils.ts +34 -0
  75. package/src/public/entry-projection.ts +42 -0
  76. package/src/public/idempotency.ts +19 -0
  77. package/src/public/problem-details.ts +5 -0
  78. package/src/public/public-add.ts +110 -166
  79. package/src/public/public-edit.ts +4 -3
  80. package/src/public/public-read.ts +59 -216
  81. package/src/public/public-routes.ts +2 -2
  82. package/src/public/query-builder.test.ts +220 -0
  83. package/src/public/rate-limit-middleware.ts +7 -19
  84. package/src/public/read-list.ts +50 -0
  85. package/src/public/read-single.ts +44 -0
  86. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  87. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  88. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  89. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  90. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  91. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  92. package/src/search-utils.test.ts +207 -0
  93. package/src/search-utils.ts +18 -1
  94. package/src/search.ts +24 -35
  95. package/src/shared/apply-policies.test.ts +77 -0
  96. package/src/shared/automations.repository.d1.ts +146 -0
  97. package/src/shared/background-notification-service.test.ts +58 -0
  98. package/src/shared/background-notification-service.ts +48 -0
  99. package/src/shared/content-utils.test.ts +161 -0
  100. package/src/shared/content.repository.d1.test.ts +312 -0
  101. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  102. package/src/shared/d1-activity-log.repository.ts +101 -0
  103. package/src/shared/d1-activity-logger.test.ts +82 -0
  104. package/src/shared/d1-activity-logger.ts +63 -0
  105. package/src/shared/d1-analytics.repository.test.ts +74 -0
  106. package/src/shared/d1-analytics.repository.ts +81 -0
  107. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  108. package/src/shared/d1-content-scan.repository.ts +29 -0
  109. package/src/shared/d1-notification.repository.test.ts +124 -0
  110. package/src/shared/d1-notification.repository.ts +114 -0
  111. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  112. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  113. package/src/shared/d1-search.repository.test.ts +83 -0
  114. package/src/shared/d1-search.repository.ts +84 -0
  115. package/src/shared/d1-session.repository.test.ts +121 -0
  116. package/src/shared/d1-session.repository.ts +98 -0
  117. package/src/shared/d1-user.repository.test.ts +147 -0
  118. package/src/shared/d1-user.repository.ts +109 -0
  119. package/src/shared/d1-widget.repository.test.ts +217 -0
  120. package/src/shared/d1-widget.repository.ts +337 -0
  121. package/src/shared/execution-context-scheduler.ts +9 -0
  122. package/src/shared/fixed-clock.ts +21 -0
  123. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  124. package/src/shared/in-memory-activity-logger.ts +15 -0
  125. package/src/shared/in-memory-notification-service.ts +15 -0
  126. package/src/shared/media.repository.d1.test.ts +103 -0
  127. package/src/shared/media.repository.d1.ts +1 -1
  128. package/src/shared/request-utils.ts +22 -0
  129. package/src/shared/sequential-id-generator.ts +22 -0
  130. package/src/shared/storage-utils.ts +3 -3
  131. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  132. package/src/types.ts +24 -3
  133. package/src/upload.ts +17 -9
  134. package/src/widget.ts +112 -253
  135. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
  136. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  137. package/src/shared/activity-logger.ts +0 -79
  138. 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,7 @@
1
+ import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
2
+
3
+ export class NoOpRateLimiter implements IRateLimiter {
4
+ async checkLimit(_key: string): Promise<RateLimitResult> {
5
+ return { isAllowed: true }
6
+ }
7
+ }
@@ -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
+ })
@@ -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
- mapFtsRow,
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("*", async (c, next) => {
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 q = c.req.query("q")?.trim() ?? ""
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 (q.length < 2) {
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 = Object.values(c.get('seedRegistry'))
29
+ const seeds = c.get('seedRegistry').all()
30
+ const searchRepository = c.get('searchRepository')
37
31
 
38
- let queryParts: ReturnType<typeof buildFtsQuery>
39
- try {
40
- queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
41
- } catch (e) {
42
- if ((e as Error).message === "EMPTY_QUERY") {
43
- return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
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 { sql, binds, countSql, countBinds } = queryParts
49
-
50
- const [ftsResult, countResult] = await Promise.all([
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 rows = ftsResult.results ?? []
56
- const total = countResult?.total ?? 0
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 nextCursor = hasMore
62
- ? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
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(row => mapFtsRow(row))
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
+ }