@beechcms/api 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +80 -80
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +22 -10
- package/src/features/content/handlers/delete.ts +21 -10
- package/src/features/content/handlers/update.ts +21 -9
- package/src/features/draft/draft.handler.ts +51 -153
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +64 -176
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/index.ts +40 -8
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +72 -5
- package/src/middleware.ts +15 -35
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +110 -166
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +59 -216
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- package/src/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/automations.repository.d1.ts +146 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.test.ts +76 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/execution-context-scheduler.ts +9 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage-utils.ts +3 -3
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +24 -3
- package/src/upload.ts +17 -9
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-CewtCjom.css +0 -1
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -1,48 +1,50 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import { Hono } from 'hono'
|
|
2
|
+
import { Context, Hono } from 'hono'
|
|
3
3
|
import {
|
|
4
4
|
validateAndSanitizeSeedPayload,
|
|
5
|
-
resolvePolicies
|
|
6
|
-
EntryNotFoundError
|
|
5
|
+
resolvePolicies
|
|
7
6
|
} from '@beechcms/core'
|
|
8
7
|
import { publicProblem } from '../../public/problem-details'
|
|
9
|
-
import { logActivity } from '../../shared/activity-logger'
|
|
10
8
|
import { cleanStr } from '../../shared/query-utils'
|
|
11
9
|
import { applyVisibility } from '../../shared/apply-policies'
|
|
12
10
|
import { AppEnv } from '../../types'
|
|
13
11
|
import { CONTENT_ERRORS } from '../content/constants'
|
|
12
|
+
import { draftGuard } from './draft.middleware'
|
|
14
13
|
|
|
15
14
|
const draftApp = new Hono<AppEnv>()
|
|
16
15
|
|
|
16
|
+
|
|
17
17
|
function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
18
18
|
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
function logDraftActivity(
|
|
22
|
+
context: Context<AppEnv>,
|
|
23
|
+
id: string,
|
|
24
|
+
slug: string,
|
|
25
|
+
title: string,
|
|
26
|
+
note: 'draft saved' | 'draft published'
|
|
27
|
+
) {
|
|
28
|
+
const actor = context.get('jwtPayload')
|
|
29
|
+
context.get('activityLogger').log({
|
|
30
|
+
action: 'update',
|
|
31
|
+
entityType: 'content',
|
|
32
|
+
entityId: id,
|
|
33
|
+
entitySlug: slug,
|
|
34
|
+
details: { title, note },
|
|
35
|
+
actor: {
|
|
36
|
+
id: actor.sub,
|
|
37
|
+
email: actor.email ?? 'unknown',
|
|
38
|
+
name: actor.name ?? null,
|
|
39
|
+
},
|
|
27
40
|
})
|
|
28
41
|
}
|
|
29
42
|
|
|
30
|
-
// PUT /:slug/:id/draft —
|
|
31
|
-
draftApp.put('/:slug/:id/draft', async (context) => {
|
|
43
|
+
// PUT /:slug/:id/draft — Creates or overwrites the pending draft
|
|
44
|
+
draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
|
|
32
45
|
const slug = context.req.param('slug')
|
|
33
46
|
const id = context.req.param('id')
|
|
34
|
-
|
|
35
|
-
const seed = context.get('getSeed')(slug)
|
|
36
|
-
if (!seed) {
|
|
37
|
-
return publicProblem(context, {
|
|
38
|
-
type: 'content-seed-not-found',
|
|
39
|
-
title: 'Not Found',
|
|
40
|
-
status: 404,
|
|
41
|
-
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (!seed.allowDrafts) return draftNotAllowed(context)
|
|
47
|
+
const seed = context.get('getSeed')(slug)!
|
|
46
48
|
|
|
47
49
|
let body: Record<string, unknown>
|
|
48
50
|
try {
|
|
@@ -56,22 +58,6 @@ draftApp.put('/:slug/:id/draft', async (context) => {
|
|
|
56
58
|
})
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
const repository = context.get('repository')
|
|
60
|
-
try {
|
|
61
|
-
// Verify entry existence
|
|
62
|
-
await repository.findById(seed, id)
|
|
63
|
-
} catch (error) {
|
|
64
|
-
if (error instanceof EntryNotFoundError) {
|
|
65
|
-
return publicProblem(context, {
|
|
66
|
-
type: 'content-not-found',
|
|
67
|
-
title: 'Not Found',
|
|
68
|
-
status: 404,
|
|
69
|
-
detail: CONTENT_ERRORS.NOT_FOUND
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
throw error
|
|
73
|
-
}
|
|
74
|
-
|
|
75
61
|
const sensitiveAliases = Object.keys(body).filter((alias) => {
|
|
76
62
|
const branch = seed.branches.find((b) => b.alias === alias)
|
|
77
63
|
return branch != null && resolvePolicies(branch).privacy !== 'plain'
|
|
@@ -112,161 +98,73 @@ draftApp.put('/:slug/:id/draft', async (context) => {
|
|
|
112
98
|
})
|
|
113
99
|
}
|
|
114
100
|
|
|
101
|
+
const repository = context.get('repository')
|
|
115
102
|
await repository.saveDraft(seed, id, validation.data)
|
|
116
103
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
entityType: 'content',
|
|
120
|
-
entityId: id,
|
|
121
|
-
entitySlug: slug,
|
|
122
|
-
details: {
|
|
123
|
-
title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
|
|
124
|
-
note: 'draft saved'
|
|
125
|
-
},
|
|
126
|
-
})
|
|
104
|
+
const displayTitle = cleanStr(validation.data[seed.displayNameAlias]) ?? id
|
|
105
|
+
logDraftActivity(context, id, slug, displayTitle, 'draft saved')
|
|
127
106
|
|
|
128
107
|
return context.json({ success: true })
|
|
129
108
|
})
|
|
130
109
|
|
|
131
|
-
// GET /:slug/:id/draft —
|
|
132
|
-
draftApp.get('/:slug/:id/draft', async (context) => {
|
|
110
|
+
// GET /:slug/:id/draft — Retrieves the pending draft
|
|
111
|
+
draftApp.get('/:slug/:id/draft', draftGuard, async (context) => {
|
|
133
112
|
const slug = context.req.param('slug')
|
|
134
113
|
const id = context.req.param('id')
|
|
135
|
-
|
|
136
|
-
const seed = context.get('getSeed')(slug)
|
|
137
|
-
if (!seed) {
|
|
138
|
-
return publicProblem(context, {
|
|
139
|
-
type: 'content-seed-not-found',
|
|
140
|
-
title: 'Not Found',
|
|
141
|
-
status: 404,
|
|
142
|
-
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (!seed.allowDrafts) return draftNotAllowed(context)
|
|
114
|
+
const seed = context.get('getSeed')(slug)!
|
|
147
115
|
|
|
148
116
|
const repository = context.get('repository')
|
|
149
117
|
const draft = await repository.getDraft(seed, id)
|
|
150
118
|
|
|
151
119
|
if (!draft) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
detail: 'No pending draft for this entry'
|
|
159
|
-
})
|
|
160
|
-
} catch (error) {
|
|
161
|
-
if (error instanceof EntryNotFoundError) {
|
|
162
|
-
return publicProblem(context, {
|
|
163
|
-
type: 'content-not-found',
|
|
164
|
-
title: 'Not Found',
|
|
165
|
-
status: 404,
|
|
166
|
-
detail: CONTENT_ERRORS.NOT_FOUND
|
|
167
|
-
})
|
|
168
|
-
}
|
|
169
|
-
throw error
|
|
170
|
-
}
|
|
120
|
+
return publicProblem(context, {
|
|
121
|
+
type: 'draft-not-found',
|
|
122
|
+
title: 'Not Found',
|
|
123
|
+
status: 404,
|
|
124
|
+
detail: 'No pending draft for this entry'
|
|
125
|
+
})
|
|
171
126
|
}
|
|
172
127
|
|
|
173
128
|
return context.json({ data: applyVisibility(draft, seed) })
|
|
174
129
|
})
|
|
175
130
|
|
|
176
|
-
// POST /:slug/:id/draft/publish —
|
|
177
|
-
draftApp.post('/:slug/:id/draft/publish', async (context) => {
|
|
131
|
+
// POST /:slug/:id/draft/publish — Atomically promotes draft to live
|
|
132
|
+
draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
|
|
178
133
|
const slug = context.req.param('slug')
|
|
179
134
|
const id = context.req.param('id')
|
|
180
|
-
|
|
181
|
-
const seed = context.get('getSeed')(slug)
|
|
182
|
-
if (!seed) {
|
|
183
|
-
return publicProblem(context, {
|
|
184
|
-
type: 'content-seed-not-found',
|
|
185
|
-
title: 'Not Found',
|
|
186
|
-
status: 404,
|
|
187
|
-
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
188
|
-
})
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (!seed.allowDrafts) return draftNotAllowed(context)
|
|
135
|
+
const seed = context.get('getSeed')(slug)!
|
|
192
136
|
|
|
193
137
|
const repository = context.get('repository')
|
|
194
138
|
const draft = await repository.getDraft(seed, id)
|
|
195
139
|
|
|
196
140
|
if (!draft) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
detail: 'No pending draft to publish'
|
|
204
|
-
})
|
|
205
|
-
} catch (error) {
|
|
206
|
-
if (error instanceof EntryNotFoundError) {
|
|
207
|
-
return publicProblem(context, {
|
|
208
|
-
type: 'content-not-found',
|
|
209
|
-
title: 'Not Found',
|
|
210
|
-
status: 404,
|
|
211
|
-
detail: CONTENT_ERRORS.NOT_FOUND
|
|
212
|
-
})
|
|
213
|
-
}
|
|
214
|
-
throw error
|
|
215
|
-
}
|
|
141
|
+
return publicProblem(context, {
|
|
142
|
+
type: 'draft-not-found',
|
|
143
|
+
title: 'Not Found',
|
|
144
|
+
status: 404,
|
|
145
|
+
detail: 'No pending draft to publish'
|
|
146
|
+
})
|
|
216
147
|
}
|
|
217
148
|
|
|
218
149
|
await repository.publishDraft(seed, id)
|
|
219
150
|
|
|
220
151
|
const displayValue = draft[seed.displayNameAlias]
|
|
221
152
|
const displayStr = typeof displayValue === 'string' ? displayValue : id
|
|
222
|
-
|
|
223
|
-
logActivity(context, {
|
|
224
|
-
action: 'update',
|
|
225
|
-
entityType: 'content',
|
|
226
|
-
entityId: id,
|
|
227
|
-
entitySlug: slug,
|
|
228
|
-
details: { title: displayStr, note: 'draft published' },
|
|
229
|
-
})
|
|
153
|
+
logDraftActivity(context, id, slug, displayStr, 'draft published')
|
|
230
154
|
|
|
231
155
|
return context.json({ success: true })
|
|
232
156
|
})
|
|
233
157
|
|
|
234
|
-
// DELETE /:slug/:id/draft —
|
|
235
|
-
draftApp.delete('/:slug/:id/draft', async (context) => {
|
|
158
|
+
// DELETE /:slug/:id/draft — Discards the pending draft
|
|
159
|
+
draftApp.delete('/:slug/:id/draft', draftGuard, async (context) => {
|
|
236
160
|
const slug = context.req.param('slug')
|
|
237
161
|
const id = context.req.param('id')
|
|
238
|
-
|
|
239
|
-
const seed = context.get('getSeed')(slug)
|
|
240
|
-
if (!seed) {
|
|
241
|
-
return publicProblem(context, {
|
|
242
|
-
type: 'content-seed-not-found',
|
|
243
|
-
title: 'Not Found',
|
|
244
|
-
status: 404,
|
|
245
|
-
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
246
|
-
})
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (!seed.allowDrafts) return draftNotAllowed(context)
|
|
162
|
+
const seed = context.get('getSeed')(slug)!
|
|
250
163
|
|
|
251
164
|
const repository = context.get('repository')
|
|
252
|
-
try {
|
|
253
|
-
await repository.findById(seed, id)
|
|
254
|
-
} catch (error) {
|
|
255
|
-
if (error instanceof EntryNotFoundError) {
|
|
256
|
-
return publicProblem(context, {
|
|
257
|
-
type: 'content-not-found',
|
|
258
|
-
title: 'Not Found',
|
|
259
|
-
status: 404,
|
|
260
|
-
detail: CONTENT_ERRORS.NOT_FOUND
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
throw error
|
|
264
|
-
}
|
|
265
|
-
|
|
266
165
|
await repository.deleteDraft(seed, id)
|
|
267
166
|
|
|
268
167
|
return context.json({ success: true })
|
|
269
168
|
})
|
|
270
169
|
|
|
271
170
|
export { draftApp }
|
|
272
|
-
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import { EntryNotFoundError } from '@beechcms/core'
|
|
3
|
+
import { publicProblem } from '../../public/problem-details'
|
|
4
|
+
import { CONTENT_ERRORS } from '../content/constants'
|
|
5
|
+
import type { AppEnv } from '../../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Middleware that guards draft-related endpoints.
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* 1. Validates that the targeted Seed schema exists.
|
|
11
|
+
* 2. Ensures the Seed configuration explicitly allows drafts.
|
|
12
|
+
* 3. Confirms that the main live content entry exists in the repository.
|
|
13
|
+
*/
|
|
14
|
+
export const draftGuard = createMiddleware<AppEnv>(async (context, next) => {
|
|
15
|
+
const slug = context.req.param('slug')
|
|
16
|
+
const id = context.req.param('id')
|
|
17
|
+
|
|
18
|
+
if (!slug || !id) {
|
|
19
|
+
return publicProblem(context, {
|
|
20
|
+
type: 'content-invalid-request',
|
|
21
|
+
title: 'Bad Request',
|
|
22
|
+
status: 400,
|
|
23
|
+
detail: 'Missing required route parameters',
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const seed = context.get('getSeed')(slug)
|
|
28
|
+
if (!seed) {
|
|
29
|
+
return publicProblem(context, {
|
|
30
|
+
type: 'content-seed-not-found',
|
|
31
|
+
title: 'Not Found',
|
|
32
|
+
status: 404,
|
|
33
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!seed.allowDrafts) {
|
|
38
|
+
return publicProblem(context, {
|
|
39
|
+
type: 'draft-not-allowed',
|
|
40
|
+
title: 'Method Not Allowed',
|
|
41
|
+
status: 405,
|
|
42
|
+
detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const repository = context.get('repository')
|
|
47
|
+
try {
|
|
48
|
+
await repository.findById(seed, id)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof EntryNotFoundError) {
|
|
51
|
+
return publicProblem(context, {
|
|
52
|
+
type: 'content-not-found',
|
|
53
|
+
title: 'Not Found',
|
|
54
|
+
status: 404,
|
|
55
|
+
detail: CONTENT_ERRORS.NOT_FOUND,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await next()
|
|
62
|
+
})
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
import { ResendEmailProvider } from './providers/resend'
|
|
17
17
|
import { buildPasswordResetEmail } from './templates/password-reset'
|
|
18
18
|
import { buildPasswordChangedEmail } from './templates/password-changed'
|
|
19
|
+
import { buildAutomationEmail } from './templates/automation-mail'
|
|
19
20
|
import type { EmailProvider } from './email.provider'
|
|
20
21
|
import type {
|
|
21
22
|
PasswordResetEmailParams,
|
|
22
23
|
PasswordChangedEmailParams,
|
|
24
|
+
AutomationMailParams,
|
|
23
25
|
} from './email.types'
|
|
24
26
|
|
|
25
27
|
/** Default sender address (Resend test sender, works without a verified domain). */
|
|
@@ -78,3 +80,14 @@ export async function sendPasswordChangedEmail(
|
|
|
78
80
|
html,
|
|
79
81
|
})
|
|
80
82
|
}
|
|
83
|
+
|
|
84
|
+
export async function sendAutomationMail(params: AutomationMailParams): Promise<void> {
|
|
85
|
+
const provider = createProvider(params.apiKey ?? params.resendApiKey ?? '', false)
|
|
86
|
+
const message = buildAutomationEmail(params)
|
|
87
|
+
await provider.send({
|
|
88
|
+
from: params.from ?? DEFAULT_FROM,
|
|
89
|
+
to: [message.to],
|
|
90
|
+
subject: message.subject,
|
|
91
|
+
html: message.html,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
@@ -96,3 +96,13 @@ export interface PasswordResetEmailParams extends BaseEmailParams {
|
|
|
96
96
|
|
|
97
97
|
/** Parameters for the "password changed" notification. No additional fields. */
|
|
98
98
|
export type PasswordChangedEmailParams = BaseEmailParams
|
|
99
|
+
|
|
100
|
+
export interface AutomationMailParams {
|
|
101
|
+
to: string
|
|
102
|
+
subject: string
|
|
103
|
+
/** Plain text or HTML — passed verbatim to provider. */
|
|
104
|
+
body: string
|
|
105
|
+
apiKey?: string
|
|
106
|
+
resendApiKey?: string
|
|
107
|
+
from?: string
|
|
108
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* PasswordChangedEmailParams — shape dei parametri per sendPasswordChangedEmail
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
export { sendPasswordResetEmail, sendPasswordChangedEmail } from './email.service'
|
|
19
|
+
export { sendPasswordResetEmail, sendPasswordChangedEmail, sendAutomationMail } from './email.service'
|
|
20
20
|
export {
|
|
21
21
|
resolveEmailLocale,
|
|
22
22
|
SUPPORTED_EMAIL_LOCALES,
|
|
@@ -25,4 +25,5 @@ export type {
|
|
|
25
25
|
EmailLocale,
|
|
26
26
|
PasswordResetEmailParams,
|
|
27
27
|
PasswordChangedEmailParams,
|
|
28
|
+
AutomationMailParams,
|
|
28
29
|
} from './email.types'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AutomationMailParams } from '../email.types'
|
|
2
|
+
|
|
3
|
+
/** Identity builder: automation payloads are already user-authored. */
|
|
4
|
+
export function buildAutomationEmail(params: AutomationMailParams) {
|
|
5
|
+
return {
|
|
6
|
+
to: params.to,
|
|
7
|
+
subject: params.subject,
|
|
8
|
+
html: params.body,
|
|
9
|
+
text: stripHtml(params.body),
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stripHtml(input: string): string {
|
|
14
|
+
return input.replace(/<[^>]+>/g, '').trim()
|
|
15
|
+
}
|
|
@@ -3,47 +3,40 @@ import { Hono } from 'hono'
|
|
|
3
3
|
import type { Env, Variables } from '../../types'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Notifications Feature Handler
|
|
6
|
+
* Notifications Feature Handler.
|
|
7
7
|
*
|
|
8
|
-
* Manages
|
|
9
|
-
*
|
|
8
|
+
* Manages retrieval, mark read/unread, and deletion of system notifications.
|
|
9
|
+
* All persistence goes through the {@link INotificationRepository} injected
|
|
10
|
+
* by `repositoryMiddleware`. The handler owns the HTTP concerns only:
|
|
11
|
+
* ETag negotiation, status codes, error mapping.
|
|
10
12
|
*/
|
|
11
13
|
const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
12
14
|
|
|
15
|
+
const NOTIFICATION_LIST_LIMIT = 50
|
|
16
|
+
|
|
13
17
|
/**
|
|
14
18
|
* GET /notifications
|
|
15
|
-
*
|
|
16
|
-
*
|
|
19
|
+
*
|
|
20
|
+
* Returns the most recent notifications. The ETag is built from aggregate
|
|
21
|
+
* stats so unchanged inboxes return 304 without serialising the full list.
|
|
17
22
|
*/
|
|
18
23
|
notificationsApp.get('/notifications', async (context) => {
|
|
19
24
|
try {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const stats = await DB.prepare(
|
|
24
|
-
'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
|
|
25
|
-
).first<{ count: number; latest: number | null; read_sum: number | null }>()
|
|
26
|
-
|
|
27
|
-
const totalCount = stats?.count ?? 0
|
|
28
|
-
const lastUpdateTimestamp = stats?.latest ?? 0
|
|
29
|
-
const totalReadCount = stats?.read_sum ?? 0
|
|
30
|
-
|
|
31
|
-
// ETag is derived from count, latest timestamp, and read status to ensure freshness
|
|
32
|
-
const etag = `W/"${totalCount}-${lastUpdateTimestamp}-${totalReadCount}"`
|
|
25
|
+
const notificationRepository = context.get('notificationRepository')
|
|
26
|
+
const notificationStats = await notificationRepository.stats()
|
|
27
|
+
const etagValue = `W/"${notificationStats.totalCount}-${notificationStats.latestCreatedAt}-${notificationStats.readCount}"`
|
|
33
28
|
|
|
34
29
|
const ifNoneMatch = context.req.header('If-None-Match')
|
|
35
|
-
if (ifNoneMatch ===
|
|
30
|
+
if (ifNoneMatch === etagValue) {
|
|
36
31
|
return new Response(null, { status: 304 })
|
|
37
32
|
}
|
|
38
33
|
|
|
39
|
-
const
|
|
40
|
-
'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
|
|
41
|
-
).all()
|
|
34
|
+
const notifications = await notificationRepository.list(NOTIFICATION_LIST_LIMIT)
|
|
42
35
|
|
|
43
|
-
context.header('ETag',
|
|
36
|
+
context.header('ETag', etagValue)
|
|
44
37
|
context.header('Cache-Control', 'no-cache, must-revalidate')
|
|
45
38
|
|
|
46
|
-
return context.json(
|
|
39
|
+
return context.json(notifications)
|
|
47
40
|
} catch (error) {
|
|
48
41
|
console.error('[Notifications] Fetch error:', error)
|
|
49
42
|
return context.json({ error: 'Failed to fetch notifications' }, 500)
|
|
@@ -51,18 +44,12 @@ notificationsApp.get('/notifications', async (context) => {
|
|
|
51
44
|
})
|
|
52
45
|
|
|
53
46
|
/**
|
|
54
|
-
* PATCH /notifications/:id/read
|
|
55
|
-
* Marks a specific notification as read.
|
|
47
|
+
* PATCH /notifications/:id/read — mark a single notification as read.
|
|
56
48
|
*/
|
|
57
49
|
notificationsApp.patch('/notifications/:id/read', async (context) => {
|
|
58
50
|
try {
|
|
59
51
|
const notificationId = context.req.param('id')
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
|
|
63
|
-
.bind(notificationId)
|
|
64
|
-
.run()
|
|
65
|
-
|
|
52
|
+
await context.get('notificationRepository').markRead(notificationId)
|
|
66
53
|
return context.json({ success: true })
|
|
67
54
|
} catch (error) {
|
|
68
55
|
console.error('[Notifications] Mark read error:', error)
|
|
@@ -71,18 +58,12 @@ notificationsApp.patch('/notifications/:id/read', async (context) => {
|
|
|
71
58
|
})
|
|
72
59
|
|
|
73
60
|
/**
|
|
74
|
-
* PATCH /notifications/:id/unread
|
|
75
|
-
* Marks a specific notification as unread.
|
|
61
|
+
* PATCH /notifications/:id/unread — mark a single notification as unread.
|
|
76
62
|
*/
|
|
77
63
|
notificationsApp.patch('/notifications/:id/unread', async (context) => {
|
|
78
64
|
try {
|
|
79
65
|
const notificationId = context.req.param('id')
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
|
|
83
|
-
.bind(notificationId)
|
|
84
|
-
.run()
|
|
85
|
-
|
|
66
|
+
await context.get('notificationRepository').markUnread(notificationId)
|
|
86
67
|
return context.json({ success: true })
|
|
87
68
|
} catch (error) {
|
|
88
69
|
console.error('[Notifications] Mark unread error:', error)
|
|
@@ -91,18 +72,12 @@ notificationsApp.patch('/notifications/:id/unread', async (context) => {
|
|
|
91
72
|
})
|
|
92
73
|
|
|
93
74
|
/**
|
|
94
|
-
* DELETE /notifications/:id
|
|
95
|
-
* Permanently deletes a notification.
|
|
75
|
+
* DELETE /notifications/:id — permanently remove a notification.
|
|
96
76
|
*/
|
|
97
77
|
notificationsApp.delete('/notifications/:id', async (context) => {
|
|
98
78
|
try {
|
|
99
79
|
const notificationId = context.req.param('id')
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await DB.prepare('DELETE FROM notifications WHERE id = ?')
|
|
103
|
-
.bind(notificationId)
|
|
104
|
-
.run()
|
|
105
|
-
|
|
80
|
+
await context.get('notificationRepository').delete(notificationId)
|
|
106
81
|
return context.json({ success: true })
|
|
107
82
|
} catch (error) {
|
|
108
83
|
console.error('[Notifications] Delete error:', error)
|
|
@@ -111,15 +86,11 @@ notificationsApp.delete('/notifications/:id', async (context) => {
|
|
|
111
86
|
})
|
|
112
87
|
|
|
113
88
|
/**
|
|
114
|
-
* POST /notifications/mark-all-read
|
|
115
|
-
* Marks all notifications in the database as read.
|
|
89
|
+
* POST /notifications/mark-all-read — mark every notification as read.
|
|
116
90
|
*/
|
|
117
91
|
notificationsApp.post('/notifications/mark-all-read', async (context) => {
|
|
118
92
|
try {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await DB.prepare('UPDATE notifications SET is_read = 1').run()
|
|
122
|
-
|
|
93
|
+
await context.get('notificationRepository').markAllRead()
|
|
123
94
|
return context.json({ success: true })
|
|
124
95
|
} catch (error) {
|
|
125
96
|
console.error('[Notifications] Mark all read error:', error)
|
|
@@ -2,22 +2,11 @@
|
|
|
2
2
|
import type { Context } from 'hono'
|
|
3
3
|
import type { Env, Variables } from '../../types'
|
|
4
4
|
import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
|
|
5
|
+
import { sha256hex } from '@beechcms/core'
|
|
6
|
+
import { getClientIp } from '../../shared/request-utils'
|
|
5
7
|
|
|
6
8
|
const PASSWORD_RESET_TOKEN_EXPIRY_SECONDS = 30 * 60
|
|
7
9
|
|
|
8
|
-
/**
|
|
9
|
-
* Computes the SHA-256 hash of a string and returns it as a hex string.
|
|
10
|
-
*/
|
|
11
|
-
async function computeSha256Hash(text: string): Promise<string> {
|
|
12
|
-
const encoder = new TextEncoder()
|
|
13
|
-
const data = encoder.encode(text)
|
|
14
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
15
|
-
|
|
16
|
-
return Array.from(new Uint8Array(hashBuffer))
|
|
17
|
-
.map(byte => byte.toString(16).padStart(2, '0'))
|
|
18
|
-
.join('')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
10
|
/**
|
|
22
11
|
* Handles the password reset request.
|
|
23
12
|
* Generates a reset token, stores its hash in the database, and sends an email to the user.
|
|
@@ -46,41 +35,30 @@ export async function requestPasswordReset(
|
|
|
46
35
|
const normalizedEmail = emailInput.trim().toLowerCase()
|
|
47
36
|
const emailLocale = resolveEmailLocale(payload.locale)
|
|
48
37
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!isRateLimitAllowed) {
|
|
55
|
-
return context.json({ error: 'Too many requests' }, 429)
|
|
56
|
-
}
|
|
38
|
+
const clientIpAddress = getClientIp(req)
|
|
39
|
+
const forgotPasswordRateLimit = await context.get('rateLimiters').getLimiter('forgotPassword').checkLimit(clientIpAddress)
|
|
40
|
+
if (!forgotPasswordRateLimit.isAllowed) {
|
|
41
|
+
return context.json({ error: 'Too many requests' }, 429)
|
|
57
42
|
}
|
|
58
43
|
|
|
59
|
-
//
|
|
60
|
-
const registeredUser = await
|
|
61
|
-
.prepare('SELECT id FROM users WHERE email = ?')
|
|
62
|
-
.bind(normalizedEmail)
|
|
63
|
-
.first<{ id: string }>()
|
|
64
|
-
|
|
44
|
+
// Always return 200 even when the user is not found to prevent user enumeration.
|
|
45
|
+
const registeredUser = await context.get('userRepository').findByEmail(normalizedEmail)
|
|
65
46
|
if (!registeredUser) {
|
|
66
47
|
return context.json({ success: true })
|
|
67
48
|
}
|
|
68
49
|
|
|
69
|
-
|
|
70
|
-
await
|
|
71
|
-
.prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
|
|
72
|
-
.bind(registeredUser.id)
|
|
73
|
-
.run()
|
|
50
|
+
const nowTimestamp = Math.floor(Date.now() / 1000)
|
|
51
|
+
await context.get('passwordResetTokenRepository').invalidatePending(registeredUser.id, nowTimestamp)
|
|
74
52
|
|
|
75
53
|
const resetToken = crypto.randomUUID()
|
|
76
|
-
const
|
|
77
|
-
const
|
|
54
|
+
const tokenHash = await sha256hex(resetToken)
|
|
55
|
+
const expiresAt = nowTimestamp + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
|
|
78
56
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
57
|
+
await context.get('passwordResetTokenRepository').create({
|
|
58
|
+
userId: registeredUser.id,
|
|
59
|
+
tokenHash,
|
|
60
|
+
expiresAt,
|
|
61
|
+
})
|
|
84
62
|
|
|
85
63
|
const baseUrl = (env.APP_URL ?? new URL(req.url).origin).replace(/\/$/, '')
|
|
86
64
|
const resetUrl = `${baseUrl}/admin/reset-password?token=${resetToken}`
|
|
@@ -95,7 +73,6 @@ export async function requestPasswordReset(
|
|
|
95
73
|
isDev: env.ENV !== 'production',
|
|
96
74
|
})
|
|
97
75
|
} catch (error) {
|
|
98
|
-
// Only log errors in non-production environments
|
|
99
76
|
if (env.ENV !== 'production') {
|
|
100
77
|
console.error('[password-reset] Failed to send email:', error)
|
|
101
78
|
}
|
|
@@ -103,4 +80,3 @@ export async function requestPasswordReset(
|
|
|
103
80
|
|
|
104
81
|
return context.json({ success: true })
|
|
105
82
|
}
|
|
106
|
-
|