@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.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/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
|
@@ -1,195 +1,195 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
-
import { Context, Hono } from 'hono'
|
|
7
|
-
import {
|
|
8
|
-
validateAndSanitizeSeedPayload,
|
|
9
|
-
resolvePolicies,
|
|
10
|
-
RelationTargetNotFoundError,
|
|
11
|
-
} from '@beechcms/core'
|
|
12
|
-
import { publicProblem } from '../../public/problem-details'
|
|
13
|
-
import { cleanStr } from '../../shared/query-utils'
|
|
14
|
-
import { applyVisibility } from '../../shared/apply-policies'
|
|
15
|
-
import { AppEnv } from '../../types'
|
|
16
|
-
import { CONTENT_ERRORS } from '../content/constants'
|
|
17
|
-
import { draftGuard } from './draft.middleware'
|
|
18
|
-
|
|
19
|
-
const draftApp = new Hono<AppEnv>()
|
|
20
|
-
|
|
21
|
-
// GET /drafts — Unified list of pending drafts across all draft-enabled seeds.
|
|
22
|
-
draftApp.get('/drafts', async (context) => {
|
|
23
|
-
const seeds = context.get('seedRegistry').draftEnabled()
|
|
24
|
-
const repository = context.get('repository')
|
|
25
|
-
const drafts = await repository.findPendingDrafts(seeds)
|
|
26
|
-
return context.json(drafts)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
30
|
-
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function logDraftActivity(
|
|
34
|
-
context: Context<AppEnv>,
|
|
35
|
-
id: string,
|
|
36
|
-
slug: string,
|
|
37
|
-
title: string,
|
|
38
|
-
note: 'draft saved' | 'draft published'
|
|
39
|
-
) {
|
|
40
|
-
const actor = context.get('jwtPayload')
|
|
41
|
-
context.get('activityLogger').log({
|
|
42
|
-
action: 'update',
|
|
43
|
-
entityType: 'content',
|
|
44
|
-
entityId: id,
|
|
45
|
-
entitySlug: slug,
|
|
46
|
-
details: { title, note },
|
|
47
|
-
actor: {
|
|
48
|
-
id: actor.sub,
|
|
49
|
-
email: actor.email ?? 'unknown',
|
|
50
|
-
name: [actor.name, actor.surname].filter(Boolean).join(' ') || null,
|
|
51
|
-
},
|
|
52
|
-
})
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// PUT /:slug/:id/draft — Creates or overwrites the pending draft
|
|
56
|
-
draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
|
|
57
|
-
const slug = context.req.param('slug')
|
|
58
|
-
const id = context.req.param('id')
|
|
59
|
-
const seed = context.get('getSeed')(slug)!
|
|
60
|
-
|
|
61
|
-
let body: Record<string, unknown>
|
|
62
|
-
try {
|
|
63
|
-
body = normalizeBody(await context.req.json<unknown>())
|
|
64
|
-
} catch {
|
|
65
|
-
return publicProblem(context, {
|
|
66
|
-
type: 'content-invalid-json',
|
|
67
|
-
title: 'Bad Request',
|
|
68
|
-
status: 400,
|
|
69
|
-
detail: CONTENT_ERRORS.INVALID_JSON_BODY
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const sensitiveAliases = Object.keys(body).filter((alias) => {
|
|
74
|
-
const branch = seed.branches.find((b) => b.alias === alias)
|
|
75
|
-
return branch != null && resolvePolicies(branch).privacy !== 'plain'
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
if (sensitiveAliases.length > 0) {
|
|
79
|
-
return publicProblem(context, {
|
|
80
|
-
type: 'content-sensitive-field-edit',
|
|
81
|
-
title: 'Unprocessable Entity',
|
|
82
|
-
status: 422,
|
|
83
|
-
detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const validation = validateAndSanitizeSeedPayload(seed, body, {
|
|
88
|
-
operation: 'update',
|
|
89
|
-
allowNull: true,
|
|
90
|
-
requireAtLeastOneValidField: true,
|
|
91
|
-
enforceRequiredFields: false,
|
|
92
|
-
idGenerator: context.get('idGenerator'),
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
if (validation.dangerousFields.length > 0) {
|
|
96
|
-
return publicProblem(context, {
|
|
97
|
-
type: 'content-dangerous-content',
|
|
98
|
-
title: 'Unprocessable Entity',
|
|
99
|
-
status: 422,
|
|
100
|
-
detail: `Dangerous markup in field '${validation.dangerousFields[0]}'`
|
|
101
|
-
})
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (validation.details.length > 0) {
|
|
105
|
-
return publicProblem(context, {
|
|
106
|
-
type: 'content-validation-failed',
|
|
107
|
-
title: 'Bad Request',
|
|
108
|
-
status: 400,
|
|
109
|
-
detail: 'Validation failed',
|
|
110
|
-
errors: validation.details
|
|
111
|
-
})
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const repository = context.get('repository')
|
|
115
|
-
await repository.saveDraft(seed, id, validation.data)
|
|
116
|
-
|
|
117
|
-
const displayTitle = cleanStr(validation.data[seed.displayNameAlias]) ?? id
|
|
118
|
-
logDraftActivity(context, id, slug, displayTitle, 'draft saved')
|
|
119
|
-
|
|
120
|
-
return context.json({ success: true })
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
// GET /:slug/:id/draft — Retrieves the pending draft
|
|
124
|
-
draftApp.get('/:slug/:id/draft', draftGuard, async (context) => {
|
|
125
|
-
const slug = context.req.param('slug')
|
|
126
|
-
const id = context.req.param('id')
|
|
127
|
-
const seed = context.get('getSeed')(slug)!
|
|
128
|
-
|
|
129
|
-
const repository = context.get('repository')
|
|
130
|
-
const draft = await repository.getDraft(seed, id)
|
|
131
|
-
|
|
132
|
-
if (!draft) {
|
|
133
|
-
return publicProblem(context, {
|
|
134
|
-
type: 'draft-not-found',
|
|
135
|
-
title: 'Not Found',
|
|
136
|
-
status: 404,
|
|
137
|
-
detail: 'No pending draft for this entry'
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return context.json({ data: applyVisibility(draft, seed) })
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
// POST /:slug/:id/draft/publish — Atomically promotes draft to live
|
|
145
|
-
draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
|
|
146
|
-
const slug = context.req.param('slug')
|
|
147
|
-
const id = context.req.param('id')
|
|
148
|
-
const seed = context.get('getSeed')(slug)!
|
|
149
|
-
|
|
150
|
-
const repository = context.get('repository')
|
|
151
|
-
const draft = await repository.getDraft(seed, id)
|
|
152
|
-
|
|
153
|
-
if (!draft) {
|
|
154
|
-
return publicProblem(context, {
|
|
155
|
-
type: 'draft-not-found',
|
|
156
|
-
title: 'Not Found',
|
|
157
|
-
status: 404,
|
|
158
|
-
detail: 'No pending draft to publish'
|
|
159
|
-
})
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
await repository.publishDraft(seed, id)
|
|
164
|
-
} catch (err) {
|
|
165
|
-
if (err instanceof RelationTargetNotFoundError) {
|
|
166
|
-
return publicProblem(context, {
|
|
167
|
-
type: 'relation-target-not-found',
|
|
168
|
-
title: 'Relation Target Not Found',
|
|
169
|
-
status: 422,
|
|
170
|
-
detail: `Field '${err.alias}' references '${err.targetSeed}' id='${err.value}' which does not exist`,
|
|
171
|
-
})
|
|
172
|
-
}
|
|
173
|
-
throw err
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const displayValue = draft[seed.displayNameAlias]
|
|
177
|
-
const displayStr = typeof displayValue === 'string' ? displayValue : id
|
|
178
|
-
logDraftActivity(context, id, slug, displayStr, 'draft published')
|
|
179
|
-
|
|
180
|
-
return context.json({ success: true })
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
// DELETE /:slug/:id/draft — Discards the pending draft
|
|
184
|
-
draftApp.delete('/:slug/:id/draft', draftGuard, async (context) => {
|
|
185
|
-
const slug = context.req.param('slug')
|
|
186
|
-
const id = context.req.param('id')
|
|
187
|
-
const seed = context.get('getSeed')(slug)!
|
|
188
|
-
|
|
189
|
-
const repository = context.get('repository')
|
|
190
|
-
await repository.deleteDraft(seed, id)
|
|
191
|
-
|
|
192
|
-
return context.json({ success: true })
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
export { draftApp }
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
+
import { Context, Hono } from 'hono'
|
|
7
|
+
import {
|
|
8
|
+
validateAndSanitizeSeedPayload,
|
|
9
|
+
resolvePolicies,
|
|
10
|
+
RelationTargetNotFoundError,
|
|
11
|
+
} from '@beechcms/core'
|
|
12
|
+
import { publicProblem } from '../../public/problem-details'
|
|
13
|
+
import { cleanStr } from '../../shared/query-utils'
|
|
14
|
+
import { applyVisibility } from '../../shared/apply-policies'
|
|
15
|
+
import { AppEnv } from '../../types'
|
|
16
|
+
import { CONTENT_ERRORS } from '../content/constants'
|
|
17
|
+
import { draftGuard } from './draft.middleware'
|
|
18
|
+
|
|
19
|
+
const draftApp = new Hono<AppEnv>()
|
|
20
|
+
|
|
21
|
+
// GET /drafts — Unified list of pending drafts across all draft-enabled seeds.
|
|
22
|
+
draftApp.get('/drafts', async (context) => {
|
|
23
|
+
const seeds = context.get('seedRegistry').draftEnabled()
|
|
24
|
+
const repository = context.get('repository')
|
|
25
|
+
const drafts = await repository.findPendingDrafts(seeds)
|
|
26
|
+
return context.json(drafts)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
30
|
+
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function logDraftActivity(
|
|
34
|
+
context: Context<AppEnv>,
|
|
35
|
+
id: string,
|
|
36
|
+
slug: string,
|
|
37
|
+
title: string,
|
|
38
|
+
note: 'draft saved' | 'draft published'
|
|
39
|
+
) {
|
|
40
|
+
const actor = context.get('jwtPayload')
|
|
41
|
+
context.get('activityLogger').log({
|
|
42
|
+
action: 'update',
|
|
43
|
+
entityType: 'content',
|
|
44
|
+
entityId: id,
|
|
45
|
+
entitySlug: slug,
|
|
46
|
+
details: { title, note },
|
|
47
|
+
actor: {
|
|
48
|
+
id: actor.sub,
|
|
49
|
+
email: actor.email ?? 'unknown',
|
|
50
|
+
name: [actor.name, actor.surname].filter(Boolean).join(' ') || null,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// PUT /:slug/:id/draft — Creates or overwrites the pending draft
|
|
56
|
+
draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
|
|
57
|
+
const slug = context.req.param('slug')
|
|
58
|
+
const id = context.req.param('id')
|
|
59
|
+
const seed = context.get('getSeed')(slug)!
|
|
60
|
+
|
|
61
|
+
let body: Record<string, unknown>
|
|
62
|
+
try {
|
|
63
|
+
body = normalizeBody(await context.req.json<unknown>())
|
|
64
|
+
} catch {
|
|
65
|
+
return publicProblem(context, {
|
|
66
|
+
type: 'content-invalid-json',
|
|
67
|
+
title: 'Bad Request',
|
|
68
|
+
status: 400,
|
|
69
|
+
detail: CONTENT_ERRORS.INVALID_JSON_BODY
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sensitiveAliases = Object.keys(body).filter((alias) => {
|
|
74
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
75
|
+
return branch != null && resolvePolicies(branch).privacy !== 'plain'
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (sensitiveAliases.length > 0) {
|
|
79
|
+
return publicProblem(context, {
|
|
80
|
+
type: 'content-sensitive-field-edit',
|
|
81
|
+
title: 'Unprocessable Entity',
|
|
82
|
+
status: 422,
|
|
83
|
+
detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const validation = validateAndSanitizeSeedPayload(seed, body, {
|
|
88
|
+
operation: 'update',
|
|
89
|
+
allowNull: true,
|
|
90
|
+
requireAtLeastOneValidField: true,
|
|
91
|
+
enforceRequiredFields: false,
|
|
92
|
+
idGenerator: context.get('idGenerator'),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
if (validation.dangerousFields.length > 0) {
|
|
96
|
+
return publicProblem(context, {
|
|
97
|
+
type: 'content-dangerous-content',
|
|
98
|
+
title: 'Unprocessable Entity',
|
|
99
|
+
status: 422,
|
|
100
|
+
detail: `Dangerous markup in field '${validation.dangerousFields[0]}'`
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (validation.details.length > 0) {
|
|
105
|
+
return publicProblem(context, {
|
|
106
|
+
type: 'content-validation-failed',
|
|
107
|
+
title: 'Bad Request',
|
|
108
|
+
status: 400,
|
|
109
|
+
detail: 'Validation failed',
|
|
110
|
+
errors: validation.details
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const repository = context.get('repository')
|
|
115
|
+
await repository.saveDraft(seed, id, validation.data)
|
|
116
|
+
|
|
117
|
+
const displayTitle = cleanStr(validation.data[seed.displayNameAlias]) ?? id
|
|
118
|
+
logDraftActivity(context, id, slug, displayTitle, 'draft saved')
|
|
119
|
+
|
|
120
|
+
return context.json({ success: true })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// GET /:slug/:id/draft — Retrieves the pending draft
|
|
124
|
+
draftApp.get('/:slug/:id/draft', draftGuard, async (context) => {
|
|
125
|
+
const slug = context.req.param('slug')
|
|
126
|
+
const id = context.req.param('id')
|
|
127
|
+
const seed = context.get('getSeed')(slug)!
|
|
128
|
+
|
|
129
|
+
const repository = context.get('repository')
|
|
130
|
+
const draft = await repository.getDraft(seed, id)
|
|
131
|
+
|
|
132
|
+
if (!draft) {
|
|
133
|
+
return publicProblem(context, {
|
|
134
|
+
type: 'draft-not-found',
|
|
135
|
+
title: 'Not Found',
|
|
136
|
+
status: 404,
|
|
137
|
+
detail: 'No pending draft for this entry'
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return context.json({ data: applyVisibility(draft, seed) })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// POST /:slug/:id/draft/publish — Atomically promotes draft to live
|
|
145
|
+
draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
|
|
146
|
+
const slug = context.req.param('slug')
|
|
147
|
+
const id = context.req.param('id')
|
|
148
|
+
const seed = context.get('getSeed')(slug)!
|
|
149
|
+
|
|
150
|
+
const repository = context.get('repository')
|
|
151
|
+
const draft = await repository.getDraft(seed, id)
|
|
152
|
+
|
|
153
|
+
if (!draft) {
|
|
154
|
+
return publicProblem(context, {
|
|
155
|
+
type: 'draft-not-found',
|
|
156
|
+
title: 'Not Found',
|
|
157
|
+
status: 404,
|
|
158
|
+
detail: 'No pending draft to publish'
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await repository.publishDraft(seed, id)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err instanceof RelationTargetNotFoundError) {
|
|
166
|
+
return publicProblem(context, {
|
|
167
|
+
type: 'relation-target-not-found',
|
|
168
|
+
title: 'Relation Target Not Found',
|
|
169
|
+
status: 422,
|
|
170
|
+
detail: `Field '${err.alias}' references '${err.targetSeed}' id='${err.value}' which does not exist`,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
throw err
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const displayValue = draft[seed.displayNameAlias]
|
|
177
|
+
const displayStr = typeof displayValue === 'string' ? displayValue : id
|
|
178
|
+
logDraftActivity(context, id, slug, displayStr, 'draft published')
|
|
179
|
+
|
|
180
|
+
return context.json({ success: true })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// DELETE /:slug/:id/draft — Discards the pending draft
|
|
184
|
+
draftApp.delete('/:slug/:id/draft', draftGuard, async (context) => {
|
|
185
|
+
const slug = context.req.param('slug')
|
|
186
|
+
const id = context.req.param('id')
|
|
187
|
+
const seed = context.get('getSeed')(slug)!
|
|
188
|
+
|
|
189
|
+
const repository = context.get('repository')
|
|
190
|
+
await repository.deleteDraft(seed, id)
|
|
191
|
+
|
|
192
|
+
return context.json({ success: true })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
export { draftApp }
|
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
import { createMiddleware } from 'hono/factory'
|
|
6
|
-
import { EntryNotFoundError } from '@beechcms/core'
|
|
7
|
-
import { publicProblem } from '../../public/problem-details'
|
|
8
|
-
import { CONTENT_ERRORS } from '../content/constants'
|
|
9
|
-
import type { AppEnv } from '../../types'
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Middleware that guards draft-related endpoints.
|
|
13
|
-
* Responsibilities:
|
|
14
|
-
* 1. Validates that the targeted Seed schema exists.
|
|
15
|
-
* 2. Ensures the Seed configuration explicitly allows drafts.
|
|
16
|
-
* 3. Confirms that the main live content entry exists in the repository.
|
|
17
|
-
*/
|
|
18
|
-
export const draftGuard = createMiddleware<AppEnv>(async (context, next) => {
|
|
19
|
-
const slug = context.req.param('slug')
|
|
20
|
-
const id = context.req.param('id')
|
|
21
|
-
|
|
22
|
-
if (!slug || !id) {
|
|
23
|
-
return publicProblem(context, {
|
|
24
|
-
type: 'content-invalid-request',
|
|
25
|
-
title: 'Bad Request',
|
|
26
|
-
status: 400,
|
|
27
|
-
detail: 'Missing required route parameters',
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const seed = context.get('getSeed')(slug)
|
|
32
|
-
if (!seed) {
|
|
33
|
-
return publicProblem(context, {
|
|
34
|
-
type: 'content-seed-not-found',
|
|
35
|
-
title: 'Not Found',
|
|
36
|
-
status: 404,
|
|
37
|
-
detail: CONTENT_ERRORS.SEED_NOT_FOUND,
|
|
38
|
-
})
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (!seed.allowDrafts) {
|
|
42
|
-
return publicProblem(context, {
|
|
43
|
-
type: 'draft-not-allowed',
|
|
44
|
-
title: 'Method Not Allowed',
|
|
45
|
-
status: 405,
|
|
46
|
-
detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const repository = context.get('repository')
|
|
51
|
-
try {
|
|
52
|
-
await repository.findById(seed, id)
|
|
53
|
-
} catch (error) {
|
|
54
|
-
if (error instanceof EntryNotFoundError) {
|
|
55
|
-
return publicProblem(context, {
|
|
56
|
-
type: 'content-not-found',
|
|
57
|
-
title: 'Not Found',
|
|
58
|
-
status: 404,
|
|
59
|
-
detail: CONTENT_ERRORS.NOT_FOUND,
|
|
60
|
-
})
|
|
61
|
-
}
|
|
62
|
-
throw error
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
await next()
|
|
66
|
-
})
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
import { createMiddleware } from 'hono/factory'
|
|
6
|
+
import { EntryNotFoundError } from '@beechcms/core'
|
|
7
|
+
import { publicProblem } from '../../public/problem-details'
|
|
8
|
+
import { CONTENT_ERRORS } from '../content/constants'
|
|
9
|
+
import type { AppEnv } from '../../types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Middleware that guards draft-related endpoints.
|
|
13
|
+
* Responsibilities:
|
|
14
|
+
* 1. Validates that the targeted Seed schema exists.
|
|
15
|
+
* 2. Ensures the Seed configuration explicitly allows drafts.
|
|
16
|
+
* 3. Confirms that the main live content entry exists in the repository.
|
|
17
|
+
*/
|
|
18
|
+
export const draftGuard = createMiddleware<AppEnv>(async (context, next) => {
|
|
19
|
+
const slug = context.req.param('slug')
|
|
20
|
+
const id = context.req.param('id')
|
|
21
|
+
|
|
22
|
+
if (!slug || !id) {
|
|
23
|
+
return publicProblem(context, {
|
|
24
|
+
type: 'content-invalid-request',
|
|
25
|
+
title: 'Bad Request',
|
|
26
|
+
status: 400,
|
|
27
|
+
detail: 'Missing required route parameters',
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const seed = context.get('getSeed')(slug)
|
|
32
|
+
if (!seed) {
|
|
33
|
+
return publicProblem(context, {
|
|
34
|
+
type: 'content-seed-not-found',
|
|
35
|
+
title: 'Not Found',
|
|
36
|
+
status: 404,
|
|
37
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!seed.allowDrafts) {
|
|
42
|
+
return publicProblem(context, {
|
|
43
|
+
type: 'draft-not-allowed',
|
|
44
|
+
title: 'Method Not Allowed',
|
|
45
|
+
status: 405,
|
|
46
|
+
detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const repository = context.get('repository')
|
|
51
|
+
try {
|
|
52
|
+
await repository.findById(seed, id)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof EntryNotFoundError) {
|
|
55
|
+
return publicProblem(context, {
|
|
56
|
+
type: 'content-not-found',
|
|
57
|
+
title: 'Not Found',
|
|
58
|
+
status: 404,
|
|
59
|
+
detail: CONTENT_ERRORS.NOT_FOUND,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await next()
|
|
66
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
export { draftApp } from './draft.handler'
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
export { draftApp } from './draft.handler'
|
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
import type { OutboundEmail } from './email.types'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* EmailProvider — formal contract for email sending providers.
|
|
9
|
-
*
|
|
10
|
-
* Every implementation (Resend, SendGrid, Mailgun, SMTP, ...) MUST comply
|
|
11
|
-
* with this interface. It is the only point of coupling between the email module and
|
|
12
|
-
* any third-party external service.
|
|
13
|
-
*
|
|
14
|
-
* ─── HOW TO CHANGE PROVIDER ──────────────────────────────────────────────────
|
|
15
|
-
* 1. Create a new file under `providers/` (e.g., `providers/sendgrid.ts`).
|
|
16
|
-
* 2. Export a class that implements this interface.
|
|
17
|
-
* 3. In `email.service.ts`, replace the import and instantiation of the current
|
|
18
|
-
* provider with your new class in the `createProvider()` function.
|
|
19
|
-
* 4. Update the necessary environment variables in `types.ts` and `wrangler.jsonc`.
|
|
20
|
-
* 5. No other file in the project needs to be touched.
|
|
21
|
-
* ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
-
*/
|
|
23
|
-
export interface EmailProvider {
|
|
24
|
-
/**
|
|
25
|
-
* Sends a single transactional email.
|
|
26
|
-
*
|
|
27
|
-
* @param email - The fully resolved message: sender, recipient,
|
|
28
|
-
* subject, and HTML body. Use the builders in `templates/` to
|
|
29
|
-
* construct this object correctly.
|
|
30
|
-
*
|
|
31
|
-
* @returns Promise that resolves when the provider has **accepted** the
|
|
32
|
-
* message for delivery. Acceptance does not guarantee delivery
|
|
33
|
-
* to the inbox — that depends on the recipient's server and the
|
|
34
|
-
* provider's deliverability.
|
|
35
|
-
*
|
|
36
|
-
* @throws {Error} If the provider rejects the request (failed authentication,
|
|
37
|
-
* network error, invalid payload). The caller
|
|
38
|
-
* (`email.service.ts`) is responsible for catching and handling
|
|
39
|
-
* this error appropriately.
|
|
40
|
-
*/
|
|
41
|
-
send(email: OutboundEmail): Promise<void>
|
|
42
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
import type { OutboundEmail } from './email.types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* EmailProvider — formal contract for email sending providers.
|
|
9
|
+
*
|
|
10
|
+
* Every implementation (Resend, SendGrid, Mailgun, SMTP, ...) MUST comply
|
|
11
|
+
* with this interface. It is the only point of coupling between the email module and
|
|
12
|
+
* any third-party external service.
|
|
13
|
+
*
|
|
14
|
+
* ─── HOW TO CHANGE PROVIDER ──────────────────────────────────────────────────
|
|
15
|
+
* 1. Create a new file under `providers/` (e.g., `providers/sendgrid.ts`).
|
|
16
|
+
* 2. Export a class that implements this interface.
|
|
17
|
+
* 3. In `email.service.ts`, replace the import and instantiation of the current
|
|
18
|
+
* provider with your new class in the `createProvider()` function.
|
|
19
|
+
* 4. Update the necessary environment variables in `types.ts` and `wrangler.jsonc`.
|
|
20
|
+
* 5. No other file in the project needs to be touched.
|
|
21
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
*/
|
|
23
|
+
export interface EmailProvider {
|
|
24
|
+
/**
|
|
25
|
+
* Sends a single transactional email.
|
|
26
|
+
*
|
|
27
|
+
* @param email - The fully resolved message: sender, recipient,
|
|
28
|
+
* subject, and HTML body. Use the builders in `templates/` to
|
|
29
|
+
* construct this object correctly.
|
|
30
|
+
*
|
|
31
|
+
* @returns Promise that resolves when the provider has **accepted** the
|
|
32
|
+
* message for delivery. Acceptance does not guarantee delivery
|
|
33
|
+
* to the inbox — that depends on the recipient's server and the
|
|
34
|
+
* provider's deliverability.
|
|
35
|
+
*
|
|
36
|
+
* @throws {Error} If the provider rejects the request (failed authentication,
|
|
37
|
+
* network error, invalid payload). The caller
|
|
38
|
+
* (`email.service.ts`) is responsible for catching and handling
|
|
39
|
+
* this error appropriately.
|
|
40
|
+
*/
|
|
41
|
+
send(email: OutboundEmail): Promise<void>
|
|
42
|
+
}
|