@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.
Files changed (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
@@ -1,192 +1,209 @@
1
- // apps/api/src/search-utils.ts
2
- // Pure functions — zero Hono dependencies, importable from Vitest.
3
- // v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
4
-
5
- import type { Seed } from "@beechcms/core"
6
-
7
- // ─── Types ───────────────────────────────────────────────────────────────────
8
-
9
- // Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
10
- export interface FtsRow {
11
- entry_id: string
12
- schema_slug: string
13
- slug: string | null
14
- status: string
15
- title: string | null
16
- excerpt: string
17
- rank: number
18
- }
19
-
20
- export interface SearchResultItem {
21
- id: string
22
- schema_slug: string
23
- slug: string | null
24
- status: string
25
- title: string
26
- excerpt: string
27
- data: Record<string, unknown>
28
- }
29
-
30
- export interface SearchResponse {
31
- items: SearchResultItem[]
32
- nextCursor: string | null
33
- total: number
34
- }
35
-
36
- export interface SearchQueryParams {
37
- q: string
38
- schemaSlug: string | null
39
- status: string | null
40
- limit: number // already clamped 1–50 by handler
41
- cursor: string | null
42
- }
43
-
44
- // ─── Cursor ──────────────────────────────────────────────────────────────────
45
-
46
- export function encodeCursor(rank: number, entryId: string): string {
47
- return btoa(`${rank}:${entryId}`)
48
- }
49
-
50
- export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
51
- try {
52
- const decoded = atob(cursor)
53
- const sep = decoded.lastIndexOf(":")
54
- if (sep === -1) return null
55
- return {
56
- rank: parseFloat(decoded.slice(0, sep)),
57
- entryId: decoded.slice(sep + 1),
58
- }
59
- } catch {
60
- return null
61
- }
62
- }
63
-
64
- // ─── Helpers ─────────────────────────────────────────────────────────────────
65
-
66
- function hasSearchableFts(seed: Seed): boolean {
67
- return seed.branches.some(b =>
68
- (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
69
- )
70
- }
71
-
72
- function buildMatchExpr(q: string): string {
73
- const MIN_PREFIX = 3
74
- const safeQ = q.replace(/["*^()]/g, " ").trim()
75
- const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
76
- if (terms.length === 0) throw new Error("EMPTY_QUERY")
77
-
78
- return terms
79
- .map(t => {
80
- if (/^\d+$/.test(t)) return `"${t}"`
81
- if (t.length <= MIN_PREFIX) return `"${t}"*`
82
- const prefixes: string[] = []
83
- for (let i = MIN_PREFIX; i <= t.length; i++) {
84
- prefixes.push(`"${t.slice(0, i)}"*`)
85
- }
86
- return `(${prefixes.join(" OR ")})`
87
- })
88
- .join(" ")
89
- }
90
-
91
- // ─── Query builder ───────────────────────────────────────────────────────────
92
-
93
- /**
94
- * Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
95
- * Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
96
- * Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
97
- */
98
- export function buildFtsQuery(
99
- params: SearchQueryParams,
100
- seeds: Seed[],
101
- ): {
102
- sql: string
103
- binds: unknown[]
104
- countSql: string
105
- countBinds: unknown[]
106
- } {
107
- const { q, schemaSlug, status, limit, cursor } = params
108
-
109
- const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
110
-
111
- const targetSeeds = seeds.filter(s =>
112
- hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
113
- )
114
-
115
- if (targetSeeds.length === 0) {
116
- return {
117
- sql: "SELECT NULL as entry_id, NULL as schema_slug, NULL as slug, NULL as status, NULL as title, '' as excerpt, 0 as rank WHERE 1=0",
118
- binds: [],
119
- countSql: "SELECT 0 as total",
120
- countBinds: [],
121
- }
122
- }
123
-
124
- const decoded = cursor ? decodeCursor(cursor) : null
125
-
126
- const parts: string[] = []
127
- const binds: unknown[] = []
128
- const countParts: string[] = []
129
- const countBinds: unknown[] = []
130
-
131
- for (const seed of targetSeeds) {
132
- const fts = `fts_${seed.slug}`
133
- const table = `content_${seed.slug}`
134
- const title = seed.displayNameAlias
135
-
136
- // Main query per seed
137
- const where: string[] = [`${fts} MATCH ?`]
138
- const lb: unknown[] = [matchExpr]
139
-
140
- if (status) { where.push("ce.status = ?"); lb.push(status) }
141
-
142
- if (decoded) {
143
- where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
144
- lb.push(decoded.rank, decoded.rank, decoded.entryId)
145
- }
146
-
147
- parts.push(
148
- `SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
149
- ` ce.${title} AS title,` +
150
- ` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
151
- ` bm25(${fts}) AS rank` +
152
- ` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
153
- ` WHERE ${where.join(' AND ')}`
154
- )
155
- binds.push(...lb)
156
-
157
- // Count per seed (no cursor, no limit)
158
- const cw: string[] = [`${fts} MATCH ?`]
159
- const cb: unknown[] = [matchExpr]
160
- if (status) { cw.push("ce.status = ?"); cb.push(status) }
161
-
162
- countParts.push(
163
- `SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
164
- )
165
- countBinds.push(...cb)
166
- }
167
-
168
- const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
169
- binds.push(limit + 1)
170
-
171
- const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
172
-
173
- return { sql, binds, countSql, countBinds }
174
- }
175
-
176
- // ─── Mapper ──────────────────────────────────────────────────────────────────
177
-
178
- function stripHtmlPreserveMark(html: string): string {
179
- return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
180
- }
181
-
182
- export function mapFtsRow(row: FtsRow): SearchResultItem {
183
- return {
184
- id: row.entry_id,
185
- schema_slug: row.schema_slug,
186
- slug: row.slug,
187
- status: row.status,
188
- title: row.title ?? "",
189
- excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
190
- data: {},
191
- }
192
- }
1
+ // apps/api/src/search-utils.ts
2
+ // Pure functions — zero Hono dependencies, importable from Vitest.
3
+ // v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
4
+
5
+ import type { Seed, SearchResultRow } from "@beechcms/core"
6
+
7
+ // ─── Types ───────────────────────────────────────────────────────────────────
8
+
9
+ // Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
10
+ export interface FtsRow {
11
+ entry_id: string
12
+ schema_slug: string
13
+ slug: string | null
14
+ status: string
15
+ title: string | null
16
+ excerpt: string
17
+ rank: number
18
+ }
19
+
20
+ export interface SearchResultItem {
21
+ id: string
22
+ schema_slug: string
23
+ slug: string | null
24
+ status: string
25
+ title: string
26
+ excerpt: string
27
+ data: Record<string, unknown>
28
+ }
29
+
30
+ export interface SearchResponse {
31
+ items: SearchResultItem[]
32
+ nextCursor: string | null
33
+ total: number
34
+ }
35
+
36
+ export interface SearchQueryParams {
37
+ q: string
38
+ schemaSlug: string | null
39
+ status: string | null
40
+ limit: number // already clamped 1–50 by handler
41
+ cursor: string | null
42
+ }
43
+
44
+ // ─── Cursor ──────────────────────────────────────────────────────────────────
45
+
46
+ export function encodeCursor(rank: number, entryId: string): string {
47
+ return btoa(`${rank}:${entryId}`)
48
+ }
49
+
50
+ export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
51
+ try {
52
+ const decoded = atob(cursor)
53
+ const sep = decoded.lastIndexOf(":")
54
+ if (sep === -1) return null
55
+ return {
56
+ rank: parseFloat(decoded.slice(0, sep)),
57
+ entryId: decoded.slice(sep + 1),
58
+ }
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
65
+
66
+ function hasSearchableFts(seed: Seed): boolean {
67
+ return seed.branches.some(b =>
68
+ (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
69
+ )
70
+ }
71
+
72
+ function buildMatchExpr(q: string): string {
73
+ const MIN_PREFIX = 3
74
+ const safeQ = q.replace(/["*^()]/g, " ").trim()
75
+ const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
76
+ if (terms.length === 0) throw new Error("EMPTY_QUERY")
77
+
78
+ return terms
79
+ .map(t => {
80
+ if (/^\d+$/.test(t)) return `"${t}"`
81
+ if (t.length <= MIN_PREFIX) return `"${t}"*`
82
+ const prefixes: string[] = []
83
+ for (let i = MIN_PREFIX; i <= t.length; i++) {
84
+ prefixes.push(`"${t.slice(0, i)}"*`)
85
+ }
86
+ return `(${prefixes.join(" OR ")})`
87
+ })
88
+ .join(" ")
89
+ }
90
+
91
+ // ─── Query builder ───────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
95
+ * Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
96
+ * Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
97
+ */
98
+ export function buildFtsQuery(
99
+ params: SearchQueryParams,
100
+ seeds: Seed[],
101
+ ): {
102
+ sql: string
103
+ binds: unknown[]
104
+ countSql: string
105
+ countBinds: unknown[]
106
+ } {
107
+ const { q, schemaSlug, status, limit, cursor } = params
108
+
109
+ const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
110
+
111
+ const targetSeeds = seeds.filter(s =>
112
+ hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
113
+ )
114
+
115
+ if (targetSeeds.length === 0) {
116
+ return {
117
+ sql: "SELECT NULL as entry_id, NULL as schema_slug, NULL as slug, NULL as status, NULL as title, '' as excerpt, 0 as rank WHERE 1=0",
118
+ binds: [],
119
+ countSql: "SELECT 0 as total",
120
+ countBinds: [],
121
+ }
122
+ }
123
+
124
+ const decoded = cursor ? decodeCursor(cursor) : null
125
+
126
+ const parts: string[] = []
127
+ const binds: unknown[] = []
128
+ const countParts: string[] = []
129
+ const countBinds: unknown[] = []
130
+
131
+ for (const seed of targetSeeds) {
132
+ const fts = `fts_${seed.slug}`
133
+ const table = `content_${seed.slug}`
134
+ const title = seed.displayNameAlias
135
+
136
+ // Main query per seed
137
+ const where: string[] = [`${fts} MATCH ?`]
138
+ const lb: unknown[] = [matchExpr]
139
+
140
+ if (status) { where.push("ce.status = ?"); lb.push(status) }
141
+
142
+ if (decoded) {
143
+ where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
144
+ lb.push(decoded.rank, decoded.rank, decoded.entryId)
145
+ }
146
+
147
+ parts.push(
148
+ `SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
149
+ ` ce.${title} AS title,` +
150
+ ` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
151
+ ` bm25(${fts}) AS rank` +
152
+ ` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
153
+ ` WHERE ${where.join(' AND ')}`
154
+ )
155
+ binds.push(...lb)
156
+
157
+ // Count per seed (no cursor, no limit)
158
+ const cw: string[] = [`${fts} MATCH ?`]
159
+ const cb: unknown[] = [matchExpr]
160
+ if (status) { cw.push("ce.status = ?"); cb.push(status) }
161
+
162
+ countParts.push(
163
+ `SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
164
+ )
165
+ countBinds.push(...cb)
166
+ }
167
+
168
+ const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
169
+ binds.push(limit + 1)
170
+
171
+ const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
172
+
173
+ return { sql, binds, countSql, countBinds }
174
+ }
175
+
176
+ // ─── Mapper ──────────────────────────────────────────────────────────────────
177
+
178
+ function stripHtmlPreserveMark(html: string): string {
179
+ return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
180
+ }
181
+
182
+ export function mapFtsRow(row: FtsRow): SearchResultItem {
183
+ return {
184
+ id: row.entry_id,
185
+ schema_slug: row.schema_slug,
186
+ slug: row.slug,
187
+ status: row.status,
188
+ title: row.title ?? "",
189
+ excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
190
+ data: {},
191
+ }
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
@@ -1,72 +1,61 @@
1
- // apps/api/src/search.ts
2
-
3
- import { Hono } from "hono"
4
- import type { Env, Variables } from "./types"
5
- import { authMiddleware } from "./middleware"
6
- import {
7
- buildFtsQuery,
8
- encodeCursor,
9
- mapFtsRow,
10
- type FtsRow,
11
- type SearchResponse,
12
- } from "./search-utils"
13
-
14
- export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
15
-
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
- })
22
-
23
- // GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
24
- searchRouter.get("/", async (c) => {
25
- const q = c.req.query("q")?.trim() ?? ""
26
- const schemaSlug = c.req.query("schema_slug") ?? null
27
- const status = c.req.query("status") ?? null
28
- const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
29
- const limit = Math.min(Math.max(rawLimit, 1), 50)
30
- const cursor = c.req.query("cursor") ?? null
31
-
32
- if (q.length < 2) {
33
- return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
34
- }
35
-
36
- const seeds = Object.values(c.get('seedRegistry'))
37
-
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
46
- }
47
-
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 }>(),
53
- ])
54
-
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
60
-
61
- const nextCursor = hasMore
62
- ? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
63
- : null
64
-
65
- if (pageRows.length === 0) {
66
- return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
67
- }
68
-
69
- const items = pageRows.map(row => mapFtsRow(row))
70
-
71
- return c.json({ items, nextCursor, total } satisfies SearchResponse)
72
- })
1
+ // apps/api/src/search.ts
2
+
3
+ import { Hono } from "hono"
4
+ import type { Env, Variables } from "./types"
5
+ import { authMiddleware } from "./middleware"
6
+ import {
7
+ encodeCursor,
8
+ mapSearchResultRow,
9
+ type SearchResponse,
10
+ } from "./search-utils"
11
+
12
+ export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
13
+
14
+ searchRouter.use("*", authMiddleware())
15
+
16
+ // GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
17
+ searchRouter.get("/", async (c) => {
18
+ const queryText = c.req.query("q")?.trim() ?? ""
19
+ const schemaSlug = c.req.query("schema_slug") ?? null
20
+ const status = c.req.query("status") ?? null
21
+ const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
22
+ const limit = Math.min(Math.max(rawLimit, 1), 50)
23
+ const cursor = c.req.query("cursor") ?? null
24
+
25
+ if (queryText.length < 2) {
26
+ return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
27
+ }
28
+
29
+ const seeds = c.get('seedRegistry').all()
30
+ const searchRepository = c.get('searchRepository')
31
+
32
+ const queryOptions = {
33
+ queryText,
34
+ schemaSlug,
35
+ statusFilter: status,
36
+ limit,
37
+ cursor,
38
+ }
39
+ const countOptions = { queryText, schemaSlug, statusFilter: status }
40
+
41
+ const [rawRows, countResult] = await Promise.all([
42
+ searchRepository.search(queryOptions, seeds),
43
+ searchRepository.count(countOptions, seeds),
44
+ ])
45
+
46
+ const hasMore = rawRows.length > limit
47
+ const pageRows = hasMore ? rawRows.slice(0, limit) : rawRows
48
+
49
+ const lastRow = pageRows.at(-1)
50
+ const nextCursor = hasMore && lastRow
51
+ ? encodeCursor(lastRow.rank, lastRow.entryId)
52
+ : null
53
+
54
+ if (pageRows.length === 0) {
55
+ return c.json({ items: [], nextCursor: null, total: countResult.total } satisfies SearchResponse)
56
+ }
57
+
58
+ const items = pageRows.map(mapSearchResultRow)
59
+
60
+ return c.json({ items, nextCursor, total: countResult.total } satisfies SearchResponse)
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
+ })