@beechcms/api 0.4.0-preview.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 (61) hide show
  1. package/README.md +21 -0
  2. package/migrations/0000_v040_base.sql +213 -0
  3. package/package.json +36 -0
  4. package/src/auth/constants.ts +10 -0
  5. package/src/auth/login.ts +91 -0
  6. package/src/auth/refresh.ts +127 -0
  7. package/src/content.ts +502 -0
  8. package/src/factory.ts +56 -0
  9. package/src/features/draft/draft.handler.ts +198 -0
  10. package/src/features/draft/draft.test.ts +315 -0
  11. package/src/features/draft/index.ts +1 -0
  12. package/src/features/email/email.provider.ts +38 -0
  13. package/src/features/email/email.service.ts +80 -0
  14. package/src/features/email/email.types.ts +98 -0
  15. package/src/features/email/index.ts +28 -0
  16. package/src/features/email/providers/resend.ts +63 -0
  17. package/src/features/email/templates/password-changed.ts +59 -0
  18. package/src/features/email/templates/password-reset.ts +64 -0
  19. package/src/features/email/templates/shell.ts +93 -0
  20. package/src/features/notifications/index.ts +1 -0
  21. package/src/features/notifications/notifications.handler.ts +88 -0
  22. package/src/features/password-reset/index.ts +15 -0
  23. package/src/features/password-reset/request.ts +88 -0
  24. package/src/features/password-reset/reset.ts +110 -0
  25. package/src/features/rotate-field/index.ts +1 -0
  26. package/src/features/rotate-field/rotate-field.handler.ts +82 -0
  27. package/src/features/rotate-field/rotate-field.schema.ts +9 -0
  28. package/src/features/rotate-field/rotate-field.test.ts +297 -0
  29. package/src/features/settings/settings.handler.ts +249 -0
  30. package/src/features/setup/index.ts +59 -0
  31. package/src/features/stats/index.ts +1 -0
  32. package/src/features/stats/stats.handler.ts +395 -0
  33. package/src/index.ts +344 -0
  34. package/src/media-utils.ts +78 -0
  35. package/src/middleware.ts +67 -0
  36. package/src/public/access-policy.ts +23 -0
  37. package/src/public/api-key-middleware.ts +53 -0
  38. package/src/public/index.ts +12 -0
  39. package/src/public/problem-details.ts +42 -0
  40. package/src/public/public-add.ts +183 -0
  41. package/src/public/public-edit.ts +183 -0
  42. package/src/public/public-errors.ts +15 -0
  43. package/src/public/public-read.ts +217 -0
  44. package/src/public/public-routes.ts +31 -0
  45. package/src/public/query-builder.ts +241 -0
  46. package/src/public/rate-limit-middleware.ts +42 -0
  47. package/src/public/response-builder.ts +26 -0
  48. package/src/public/sanitize.ts +65 -0
  49. package/src/public/slug-utils.ts +14 -0
  50. package/src/search-utils.ts +188 -0
  51. package/src/search.ts +72 -0
  52. package/src/shared/activity-logger.ts +79 -0
  53. package/src/shared/apply-policies.ts +63 -0
  54. package/src/shared/content-utils.ts +108 -0
  55. package/src/shared/fts-sync.ts +4 -0
  56. package/src/shared/notification-service.ts +56 -0
  57. package/src/shared/query-utils.ts +137 -0
  58. package/src/shared/storage-utils.ts +36 -0
  59. package/src/types.ts +35 -0
  60. package/src/upload.ts +335 -0
  61. package/src/widget.ts +349 -0
@@ -0,0 +1,79 @@
1
+ import { Context } from 'hono'
2
+
3
+ export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
4
+ export type EntityType = 'content' | 'media'
5
+
6
+ export interface ActivityLogParams {
7
+ action: ActivityAction
8
+ entityType: EntityType
9
+ entityId: string
10
+ entitySlug?: string
11
+ details?: Record<string, any>
12
+ }
13
+
14
+ /**
15
+ * Logga un'azione utente asincronamente nel database D1.
16
+ * Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
17
+ */
18
+ export function logActivity(
19
+ c: Context<any>,
20
+ params: ActivityLogParams
21
+ ): void {
22
+ const db = c.env.DB as D1Database
23
+ const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
24
+
25
+ if (!db || !user) return
26
+
27
+ const { action, entityType, entityId, entitySlug, details } = params
28
+ const id = crypto.randomUUID()
29
+
30
+ // Usa waitUntil per non bloccare la risposta al client
31
+ let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
32
+ try {
33
+ executionCtx = c.executionCtx
34
+ } catch {
35
+ // In ambiente di test Hono lancia se non presente
36
+ }
37
+
38
+ if (executionCtx) {
39
+ executionCtx.waitUntil((async () => {
40
+ try {
41
+ await db.prepare(
42
+ `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
44
+ ).bind(
45
+ id,
46
+ user.sub,
47
+ user.email || 'unknown',
48
+ user.name || null,
49
+ action,
50
+ entityType,
51
+ entityId,
52
+ entitySlug || null,
53
+ details ? JSON.stringify(details) : null
54
+ ).run()
55
+ } catch (err) {
56
+ console.error('Failed to log activity:', err)
57
+ }
58
+ })())
59
+ } else {
60
+ try {
61
+ db.prepare(
62
+ `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
64
+ ).bind(
65
+ id,
66
+ user.sub,
67
+ user.email || 'unknown',
68
+ user.name || null,
69
+ action,
70
+ entityType,
71
+ entityId,
72
+ entitySlug || null,
73
+ details ? JSON.stringify(details) : null
74
+ ).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
75
+ } catch (err) {
76
+ console.error('Failed to log activity (sync fallback):', err)
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,63 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { resolvePolicies, sha256hex } from '@beechcms/core'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ class PrivacyPolicyError extends Error {
6
+ readonly status = 501 as const
7
+ constructor(message: string) {
8
+ super(message)
9
+ this.name = 'PrivacyPolicyError'
10
+ }
11
+ }
12
+
13
+ export { PrivacyPolicyError }
14
+
15
+ /** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
16
+ export async function applyPrivacy(
17
+ data: Record<string, unknown>,
18
+ seed: Seed,
19
+ ): Promise<Record<string, unknown>> {
20
+ const result: Record<string, unknown> = {}
21
+ for (const [alias, value] of Object.entries(data)) {
22
+ const branch = seed.branches.find((b) => b.alias === alias)
23
+ if (!branch) {
24
+ result[alias] = value
25
+ continue
26
+ }
27
+ const { privacy } = resolvePolicies(branch)
28
+ if (privacy === 'encrypt') {
29
+ throw new PrivacyPolicyError(
30
+ `Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
31
+ )
32
+ }
33
+ if (privacy === 'hash' && value != null) {
34
+ result[alias] = await sha256hex(String(value))
35
+ } else {
36
+ result[alias] = value
37
+ }
38
+ }
39
+ return result
40
+ }
41
+
42
+ /** Applica la visibility policy ai campi del payload in uscita verso il client. */
43
+ export function applyVisibility(
44
+ data: Record<string, unknown>,
45
+ seed: Seed,
46
+ ): Record<string, unknown> {
47
+ const result: Record<string, unknown> = {}
48
+ for (const [alias, value] of Object.entries(data)) {
49
+ const branch = seed.branches.find((b) => b.alias === alias)
50
+ if (!branch) {
51
+ result[alias] = value
52
+ continue
53
+ }
54
+ const { visibility } = resolvePolicies(branch)
55
+ if (visibility === 'hidden') continue
56
+ if (visibility === 'masked') {
57
+ result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
58
+ } else {
59
+ result[alias] = value
60
+ }
61
+ }
62
+ return result
63
+ }
@@ -0,0 +1,108 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { deserializeFromDb, serializeForDb } from '@beechcms/core'
3
+ import type { Seed } from '@beechcms/core'
4
+ import type { ContentEntry } from './query-utils'
5
+
6
+ /** Deserializza ogni branch colonna di un DB row in formato API alias. */
7
+ export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
8
+ const data: Record<string, unknown> = {}
9
+ for (const branch of seed.branches) {
10
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
11
+ }
12
+ return data
13
+ }
14
+
15
+ /** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
16
+ export function rowToEntry(
17
+ seed: Seed,
18
+ row: Record<string, unknown>,
19
+ hasPendingDraft = false
20
+ ): ContentEntry {
21
+ return {
22
+ id: row.id as string,
23
+ schema_slug: seed.slug,
24
+ slug: (row.slug as string | null) ?? null,
25
+ status: (row.status as string) ?? 'draft',
26
+ data: rowToApiData(seed, row),
27
+ hasPendingDraft,
28
+ created_at: (row.created_at as number | null) ?? null,
29
+ updated_at: (row.updated_at as number | null) ?? null,
30
+ }
31
+ }
32
+
33
+ export interface InsertBindings {
34
+ cols: string[]
35
+ placeholders: string[]
36
+ bindings: (string | number | null)[]
37
+ }
38
+
39
+ /** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
40
+ export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
41
+ const cols: string[] = []
42
+ const placeholders: string[] = []
43
+ const bindings: (string | number | null)[] = []
44
+ for (const branch of seed.branches) {
45
+ if (Object.hasOwn(payload, branch.alias)) {
46
+ cols.push(branch.alias)
47
+ placeholders.push('?')
48
+ bindings.push(serializeForDb(branch, payload[branch.alias]))
49
+ }
50
+ }
51
+ return { cols, placeholders, bindings }
52
+ }
53
+
54
+ export interface UpdateBindings {
55
+ setClause: string
56
+ bindings: (string | number | null)[]
57
+ }
58
+
59
+ /** Costruisce SET clause e bindings per UPDATE content_{slug}. */
60
+ export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
61
+ const setParts: string[] = []
62
+ const bindings: (string | number | null)[] = []
63
+ for (const branch of seed.branches) {
64
+ if (Object.hasOwn(payload, branch.alias)) {
65
+ setParts.push(`${branch.alias} = ?`)
66
+ bindings.push(serializeForDb(branch, payload[branch.alias]))
67
+ }
68
+ }
69
+ return { setClause: setParts.join(', '), bindings }
70
+ }
71
+
72
+ /** Controlla se esiste un draft pendente in content_{slug}_drafts. */
73
+ export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
74
+ if (!seed.allowDrafts) return false
75
+ const row = await db
76
+ .prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
77
+ .bind(entryId)
78
+ .first()
79
+ return row !== null
80
+ }
81
+
82
+ /** Scrive un evento CRUD in content_event_log per l'activity feed. */
83
+ export async function logContentEvent(
84
+ db: D1Database,
85
+ opts: {
86
+ action: 'create' | 'update' | 'delete'
87
+ schemaSlug: string
88
+ entryId: string
89
+ userId?: string | null
90
+ details?: Record<string, unknown>
91
+ }
92
+ ): Promise<void> {
93
+ await db
94
+ .prepare(
95
+ `INSERT INTO content_event_log (id, schema_slug, entry_id, action, user_id, details, created_at)
96
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
97
+ )
98
+ .bind(
99
+ crypto.randomUUID(),
100
+ opts.schemaSlug,
101
+ opts.entryId,
102
+ opts.action,
103
+ opts.userId ?? null,
104
+ opts.details ? JSON.stringify(opts.details) : null,
105
+ Math.floor(Date.now() / 1000)
106
+ )
107
+ .run()
108
+ }
@@ -0,0 +1,4 @@
1
+ // v0.4.0: FTS sync handled automatically by SQL triggers (generateFtsTriggers).
2
+ // syncFts/deleteFts eliminated. This file kept as empty module to avoid import errors
3
+ // during the Phase 4 → Phase 6 transition.
4
+ export {}
@@ -0,0 +1,56 @@
1
+ import { Context } from 'hono'
2
+
3
+ export interface NotificationParams {
4
+ title: string
5
+ message: string
6
+ type?: 'info' | 'success' | 'warning' | 'error'
7
+ }
8
+
9
+ /**
10
+ * Crea una notifica persistente nel database D1.
11
+ * Usata principalmente per segnalare azioni provenienti dalle Public API.
12
+ */
13
+ export async function createNotification(
14
+ c: Context<any>,
15
+ params: NotificationParams
16
+ ): Promise<void> {
17
+ const db = c.env.DB as D1Database
18
+ if (!db) return
19
+
20
+ const { title, message, type = 'info' } = params
21
+ const id = crypto.randomUUID()
22
+
23
+ // Usiamo waitUntil per non bloccare la richiesta dell'utente esterno
24
+ let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
25
+ try {
26
+ executionCtx = c.executionCtx
27
+ } catch {
28
+ // In ambiente di test Hono lancia se non presente
29
+ }
30
+
31
+ if (executionCtx) {
32
+ executionCtx.waitUntil((async () => {
33
+ try {
34
+ await db.prepare(
35
+ `INSERT INTO notifications (id, title, message, type)
36
+ VALUES (?, ?, ?, ?)`
37
+ ).bind(id, title, message, type).run()
38
+ } catch (err) {
39
+ console.error('Failed to create notification:', err)
40
+ }
41
+ })())
42
+ } else {
43
+ // Fallback sync per test o ambienti senza executionCtx (se vogliamo che le notifiche siano create)
44
+ // Oppure semplicemente ignoriamo. In questo caso, per i test, meglio tentare di crearle
45
+ // ma dato che è un'azione opzionale "background", in test possiamo saltarla o farla sync.
46
+ // Facciamola sync se non c'è executionCtx per garantire che i test che verificano le notifiche (se ce ne sono) passino.
47
+ try {
48
+ await db.prepare(
49
+ `INSERT INTO notifications (id, title, message, type)
50
+ VALUES (?, ?, ?, ?)`
51
+ ).bind(id, title, message, type).run()
52
+ } catch (err) {
53
+ console.error('Failed to create notification (sync fallback):', err)
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,137 @@
1
+ import type { FilterGroup, FilterType } from '@beechcms/core'
2
+
3
+ /** Entry parsata per le risposte API — contratto immutabile (C7). */
4
+ export interface ContentEntry {
5
+ id: string
6
+ schema_slug: string
7
+ slug: string | null
8
+ status: string
9
+ data: Record<string, unknown>
10
+ hasPendingDraft: boolean
11
+ created_at: number | null
12
+ updated_at: number | null
13
+ }
14
+
15
+ export type QueryFilterType = FilterType
16
+
17
+ export type QueryFilterOperator =
18
+ | 'eq'
19
+ | 'gt'
20
+ | 'gte'
21
+ | 'lt'
22
+ | 'lte'
23
+ | 'contains'
24
+ | 'is_empty'
25
+ | 'is_not_empty'
26
+
27
+ export interface QueryFilterCondition {
28
+ id?: string
29
+ op: QueryFilterOperator
30
+ value: string | number | boolean | null
31
+ }
32
+
33
+ /** Formato inviato dalla dashboard (usa columnId). */
34
+ export interface QueryFilterGroup {
35
+ columnId: string
36
+ label?: string
37
+ type: QueryFilterType
38
+ conditions: QueryFilterCondition[]
39
+ }
40
+
41
+ const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
42
+ 'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
43
+ ])
44
+
45
+ function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
46
+ return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
47
+ }
48
+
49
+ export function cleanStr(val: unknown): string | null {
50
+ return (typeof val === 'string' && val.trim()) || null
51
+ }
52
+
53
+ export function safeParseJson(data: unknown): Record<string, unknown> {
54
+ const cleaned = cleanStr(data)
55
+ if (!cleaned) return {}
56
+ try {
57
+ const parsed = JSON.parse(cleaned)
58
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
59
+ ? (parsed as Record<string, unknown>)
60
+ : {}
61
+ } catch {
62
+ return {}
63
+ }
64
+ }
65
+
66
+ export function parsePositiveInt(value: string | undefined, fallback: number): number {
67
+ if (!value) return fallback
68
+ const parsed = Number.parseInt(value, 10)
69
+ if (Number.isNaN(parsed) || parsed < 1) return fallback
70
+ return parsed
71
+ }
72
+
73
+ function parseCondition(cond: unknown): QueryFilterCondition | null {
74
+ if (!cond || typeof cond !== 'object') return null
75
+ const candidate = cond as Record<string, unknown>
76
+ if (!isQueryFilterOperator(candidate.op)) return null
77
+ const rawValue = candidate.value
78
+ let parsedValue: QueryFilterCondition['value'] = null
79
+ if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
80
+ parsedValue = rawValue
81
+ }
82
+ return {
83
+ id: typeof candidate.id === 'string' ? candidate.id : undefined,
84
+ op: candidate.op,
85
+ value: parsedValue,
86
+ }
87
+ }
88
+
89
+ function parseFilterGroup(group: unknown): QueryFilterGroup | null {
90
+ if (!group || typeof group !== 'object') return null
91
+ const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
92
+ if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
93
+ return null
94
+ }
95
+ const validConditions: QueryFilterCondition[] = []
96
+ for (const cond of rawConds) {
97
+ const parsedCond = parseCondition(cond)
98
+ if (parsedCond) validConditions.push(parsedCond)
99
+ }
100
+ if (validConditions.length === 0) return null
101
+ return {
102
+ columnId,
103
+ label: typeof label === 'string' ? label : undefined,
104
+ type,
105
+ conditions: validConditions,
106
+ }
107
+ }
108
+
109
+ /** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
110
+ export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
111
+ if (!raw) return []
112
+ let parsed: unknown
113
+ try {
114
+ parsed = JSON.parse(raw)
115
+ } catch {
116
+ return []
117
+ }
118
+ if (!parsed || typeof parsed !== 'object') return []
119
+ const result: QueryFilterGroup[] = []
120
+ for (const group of Object.values(parsed as Record<string, unknown>)) {
121
+ const parsedGroup = parseFilterGroup(group)
122
+ if (parsedGroup) result.push(parsedGroup)
123
+ }
124
+ return result
125
+ }
126
+
127
+ /**
128
+ * Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
129
+ * compatibile con buildSelectQuery del Botanical Engine.
130
+ */
131
+ export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
132
+ return groups.map((g) => ({
133
+ column: g.columnId,
134
+ type: g.type,
135
+ conditions: g.conditions,
136
+ }))
137
+ }
@@ -0,0 +1,36 @@
1
+ import { S3Client, ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'
2
+
3
+ /**
4
+ * Calcola la dimensione totale occupata in un bucket R2 (in byte).
5
+ * Nota: esegue una scansione completa del bucket. Per bucket molto grandi
6
+ * andrebbe implementata una cache o usato Cloudflare Logpush/Analytics.
7
+ */
8
+ export async function getBucketSize(client: S3Client, bucketName: string): Promise<number> {
9
+ let totalSize = 0
10
+ let isTruncatedFlag = true
11
+ let continuationToken: string | undefined = undefined
12
+
13
+ try {
14
+ while (isTruncatedFlag) {
15
+ const command = new ListObjectsV2Command({
16
+ Bucket: bucketName,
17
+ ContinuationToken: continuationToken,
18
+ })
19
+
20
+ const response = (await client.send(command)) as ListObjectsV2CommandOutput
21
+
22
+ if (response.Contents) {
23
+ for (const obj of response.Contents) {
24
+ totalSize += obj.Size ?? 0
25
+ }
26
+ }
27
+
28
+ isTruncatedFlag = response.IsTruncated ?? false
29
+ continuationToken = response.NextContinuationToken
30
+ }
31
+ return totalSize
32
+ } catch (err) {
33
+ console.error('Error calculating bucket size:', err)
34
+ return 0
35
+ }
36
+ }
package/src/types.ts ADDED
@@ -0,0 +1,35 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Seed } from '@beechcms/core'
3
+
4
+ export interface Env {
5
+ DB: D1Database
6
+ JWT_SECRET: string
7
+ JWT_ISSUER?: string
8
+ JWT_AUDIENCE?: string
9
+ R2_ACCESS_KEY_ID?: string
10
+ R2_SECRET_ACCESS_KEY?: string
11
+ R2_ENDPOINT?: string
12
+ R2_BUCKET_NAME?: string
13
+ LOGIN_RATE_LIMITER?: RateLimit
14
+ REFRESH_RATE_LIMITER?: RateLimit
15
+ PUBLIC_READ_RATE_LIMITER?: RateLimit
16
+ PUBLIC_WRITE_RATE_LIMITER?: RateLimit
17
+ CORS_ORIGINS?: string
18
+ PUBLIC_READ_API_KEY?: string
19
+ PUBLIC_WRITE_API_KEY?: string
20
+ PUBLIC_PUBLISHED_ONLY?: string
21
+ PUBLIC_IDEMPOTENCY_TTL_SECONDS?: string
22
+ MEDIA_BASE_URL?: string
23
+ RESEND_API_KEY?: string
24
+ APP_URL?: string
25
+ EMAIL_FROM?: string
26
+ FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
27
+ RESET_PASSWORD_RATE_LIMITER?: RateLimit
28
+ ENV?: string
29
+ }
30
+
31
+ export interface Variables {
32
+ jwtPayload: { sub: string; email?: string }
33
+ getSeed: (slug: string) => Seed | null
34
+ seedRegistry: Record<string, Seed>
35
+ }