@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,9 +1,24 @@
|
|
|
1
1
|
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import type { Context } from 'hono'
|
|
2
3
|
import { D1ContentRepository } from '../shared/content.repository.d1'
|
|
3
4
|
import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
|
|
4
5
|
import { D1MediaRepository } from '../shared/media.repository.d1'
|
|
5
6
|
import { D1SystemStatsRepository } from '../shared/system-stats.repository.d1'
|
|
6
|
-
import
|
|
7
|
+
import { D1UserRepository } from '../shared/d1-user.repository'
|
|
8
|
+
import { D1SessionRepository } from '../shared/d1-session.repository'
|
|
9
|
+
import { D1PasswordResetTokenRepository } from '../shared/d1-password-reset-token.repository'
|
|
10
|
+
import { D1ActivityLogRepository } from '../shared/d1-activity-log.repository'
|
|
11
|
+
import { D1NotificationRepository } from '../shared/d1-notification.repository'
|
|
12
|
+
import { D1WidgetRepository } from '../shared/d1-widget.repository'
|
|
13
|
+
import { D1SearchRepository } from '../shared/d1-search.repository'
|
|
14
|
+
import { D1AnalyticsRepository } from '../shared/d1-analytics.repository'
|
|
15
|
+
import { D1ContentScanRepository } from '../shared/d1-content-scan.repository'
|
|
16
|
+
import { SystemClock, SystemIdGenerator } from '@beechcms/core'
|
|
17
|
+
import type { ContentRepository, IdempotencyRepository, MediaRepository, SystemStatsRepository, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogRepository, INotificationRepository, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, IClock, IIdGenerator, IAutomationRunner, IAutomationRepository, IScheduler } from '@beechcms/core'
|
|
18
|
+
import { NoOpScheduler } from '@beechcms/core'
|
|
19
|
+
import { AutomationRunner } from '../features/automations'
|
|
20
|
+
import { D1AutomationRepository } from '../shared/automations.repository.d1'
|
|
21
|
+
import { ExecutionContextScheduler } from '../shared/execution-context-scheduler'
|
|
7
22
|
import type { Env, Variables } from '../types'
|
|
8
23
|
|
|
9
24
|
interface RepositoryOverrides {
|
|
@@ -11,14 +26,66 @@ interface RepositoryOverrides {
|
|
|
11
26
|
idempotencyRepository?: IdempotencyRepository
|
|
12
27
|
mediaRepository?: MediaRepository
|
|
13
28
|
systemStatsRepository?: SystemStatsRepository
|
|
29
|
+
userRepository?: IUserRepository
|
|
30
|
+
sessionRepository?: ISessionRepository
|
|
31
|
+
passwordResetTokenRepository?: IPasswordResetTokenRepository
|
|
32
|
+
activityLogRepository?: IActivityLogRepository
|
|
33
|
+
notificationRepository?: INotificationRepository
|
|
34
|
+
widgetRepository?: IWidgetRepository
|
|
35
|
+
searchRepository?: ISearchRepository
|
|
36
|
+
analyticsRepository?: IAnalyticsRepository
|
|
37
|
+
contentScanRepository?: IContentScanRepository
|
|
38
|
+
clock?: IClock
|
|
39
|
+
idGenerator?: IIdGenerator
|
|
40
|
+
automationRepository?: IAutomationRepository
|
|
41
|
+
automationRunner?: IAutomationRunner
|
|
42
|
+
scheduler?: IScheduler
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildScheduler(context: Context): IScheduler {
|
|
46
|
+
try {
|
|
47
|
+
return new ExecutionContextScheduler(context.executionCtx)
|
|
48
|
+
} catch {
|
|
49
|
+
return new NoOpScheduler()
|
|
50
|
+
}
|
|
14
51
|
}
|
|
15
52
|
|
|
16
53
|
export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
|
|
17
54
|
return createMiddleware<{ Bindings: Env; Variables: Variables }>(async (context, next) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
55
|
+
const resolvedClock = overrides?.clock ?? SystemClock
|
|
56
|
+
const resolvedIdGenerator = overrides?.idGenerator ?? SystemIdGenerator
|
|
57
|
+
const database = context.env.DB
|
|
58
|
+
|
|
59
|
+
context.set('repository', overrides?.repository ?? new D1ContentRepository(database))
|
|
60
|
+
context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(database))
|
|
61
|
+
context.set('mediaRepository', overrides?.mediaRepository ?? new D1MediaRepository(database))
|
|
62
|
+
context.set('systemStatsRepository', overrides?.systemStatsRepository ?? new D1SystemStatsRepository(database))
|
|
63
|
+
context.set('userRepository', overrides?.userRepository ?? new D1UserRepository(database))
|
|
64
|
+
context.set('sessionRepository', overrides?.sessionRepository ?? new D1SessionRepository(database, resolvedClock))
|
|
65
|
+
context.set('passwordResetTokenRepository', overrides?.passwordResetTokenRepository ?? new D1PasswordResetTokenRepository(database, resolvedIdGenerator))
|
|
66
|
+
context.set('activityLogRepository', overrides?.activityLogRepository ?? new D1ActivityLogRepository(database))
|
|
67
|
+
context.set('notificationRepository', overrides?.notificationRepository ?? new D1NotificationRepository(database, resolvedClock, resolvedIdGenerator))
|
|
68
|
+
context.set('widgetRepository', overrides?.widgetRepository ?? new D1WidgetRepository(database))
|
|
69
|
+
context.set('searchRepository', overrides?.searchRepository ?? new D1SearchRepository(database))
|
|
70
|
+
context.set('analyticsRepository', overrides?.analyticsRepository ?? new D1AnalyticsRepository(database, resolvedClock))
|
|
71
|
+
context.set('contentScanRepository', overrides?.contentScanRepository ?? new D1ContentScanRepository(database))
|
|
72
|
+
context.set('clock', resolvedClock)
|
|
73
|
+
context.set('idGenerator', resolvedIdGenerator)
|
|
74
|
+
const automationRepository = overrides?.automationRepository
|
|
75
|
+
?? new D1AutomationRepository(database)
|
|
76
|
+
|
|
77
|
+
context.set('automationRepository', automationRepository)
|
|
78
|
+
context.set(
|
|
79
|
+
'automationRunner',
|
|
80
|
+
overrides?.automationRunner ?? new AutomationRunner({
|
|
81
|
+
automationRepository,
|
|
82
|
+
contentRepository: context.get('repository'),
|
|
83
|
+
getSeed: context.get('getSeed'),
|
|
84
|
+
idGenerator: resolvedIdGenerator,
|
|
85
|
+
env: context.env as unknown as Record<string, string | undefined>,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
context.set('scheduler', overrides?.scheduler ?? buildScheduler(context))
|
|
22
89
|
await next()
|
|
23
90
|
})
|
|
24
91
|
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
import type { Context, Next } from 'hono'
|
|
3
3
|
import { HTTPException } from 'hono/http-exception'
|
|
4
|
-
import {
|
|
4
|
+
import type { Env, Variables } from './types'
|
|
5
5
|
|
|
6
|
-
/** Payload JWT decodificato (sub = userId, email opzionale, name opzionale) */
|
|
7
6
|
export type JwtPayload = {
|
|
8
7
|
sub: string
|
|
9
8
|
email?: string
|
|
10
9
|
name?: string
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
/** Variabili iniettate nel context Hono dopo auth */
|
|
14
|
-
export type AuthVariables = {
|
|
15
|
-
jwtPayload: JwtPayload
|
|
16
|
-
}
|
|
17
|
-
|
|
18
12
|
const UNAUTHORIZED_JSON = JSON.stringify({ error: 'Unauthorized' })
|
|
19
13
|
|
|
20
14
|
function unauthorizedResponse() {
|
|
@@ -24,44 +18,30 @@ function unauthorizedResponse() {
|
|
|
24
18
|
})
|
|
25
19
|
}
|
|
26
20
|
|
|
27
|
-
export type JwtVerifyOptions = {
|
|
28
|
-
issuer?: string
|
|
29
|
-
audience?: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
21
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* Se invalido/mancante: lancia HTTPException 401 (gestita dal framework).
|
|
22
|
+
* JWT authentication middleware. Reads the Bearer token from the Authorization
|
|
23
|
+
* header and delegates verification to the ITokenService injected in context.
|
|
24
|
+
* Returns 401 on any missing or invalid token; never exposes failure details.
|
|
37
25
|
*/
|
|
38
|
-
export function authMiddleware(
|
|
39
|
-
return async (c: Context
|
|
40
|
-
const
|
|
41
|
-
if (!
|
|
26
|
+
export function authMiddleware() {
|
|
27
|
+
return async (c: Context<{ Bindings: Env; Variables: Variables }>, next: Next): Promise<Response | void> => {
|
|
28
|
+
const authHeader = c.req.header('Authorization')
|
|
29
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
42
30
|
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
43
31
|
}
|
|
44
32
|
|
|
45
|
-
const token =
|
|
33
|
+
const token = authHeader.slice(7)
|
|
46
34
|
if (!token) {
|
|
47
35
|
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
48
36
|
}
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
algorithms: ['HS256'],
|
|
54
|
-
issuer: options.issuer,
|
|
55
|
-
audience: options.audience,
|
|
56
|
-
})
|
|
57
|
-
// Hardening: accetta solo token JWT standard (se presente il typ)
|
|
58
|
-
if (protectedHeader.typ && protectedHeader.typ !== 'JWT') {
|
|
59
|
-
throw new Error('Invalid typ header')
|
|
60
|
-
}
|
|
61
|
-
c.set('jwtPayload', payload as JwtPayload)
|
|
62
|
-
await next()
|
|
63
|
-
} catch {
|
|
38
|
+
const claims = await c.get('tokenService').verify(token)
|
|
39
|
+
|
|
40
|
+
if (!claims) {
|
|
64
41
|
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
65
42
|
}
|
|
43
|
+
|
|
44
|
+
c.set('jwtPayload', claims as JwtPayload)
|
|
45
|
+
await next()
|
|
66
46
|
}
|
|
67
47
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
type EdgeCache = {
|
|
4
|
+
cache: Cache
|
|
5
|
+
executionCtx: { waitUntil: (p: Promise<unknown>) => void }
|
|
6
|
+
} | null
|
|
7
|
+
|
|
8
|
+
export function resolveEdgeCache(c: Context): EdgeCache {
|
|
9
|
+
try {
|
|
10
|
+
const cache = caches.default
|
|
11
|
+
let executionCtx: any
|
|
12
|
+
try {
|
|
13
|
+
executionCtx = c.executionCtx
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!executionCtx?.waitUntil) return null
|
|
19
|
+
return { cache, executionCtx: executionCtx as { waitUntil: (p: Promise<unknown>) => void } }
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function withCachedResponse(edgeCache: EdgeCache, cacheKey: Request, response: Response): Response {
|
|
26
|
+
if (!edgeCache) return response
|
|
27
|
+
const cloned = response.clone()
|
|
28
|
+
const headers = new Headers(cloned.headers)
|
|
29
|
+
headers.set('Cache-Control', 'public, max-age=60')
|
|
30
|
+
edgeCache.executionCtx.waitUntil(
|
|
31
|
+
edgeCache.cache.put(cacheKey, new Response(cloned.body, { status: cloned.status, headers }))
|
|
32
|
+
)
|
|
33
|
+
return response
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolvePolicies } from '@beechcms/core'
|
|
2
|
+
import type { Seed } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
const SYSTEM_FIELDS = ['id', 'slug', 'status', 'created_at', 'updated_at']
|
|
5
|
+
const IDENTITY_FIELDS = ['id', 'slug']
|
|
6
|
+
|
|
7
|
+
function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
|
|
8
|
+
const result: Record<string, unknown> = {}
|
|
9
|
+
|
|
10
|
+
for (const key of SYSTEM_FIELDS) {
|
|
11
|
+
if (key in data) result[key] = data[key]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const branch of seed.branches) {
|
|
15
|
+
const value = data[branch.alias]
|
|
16
|
+
const { public: isPublic, visibility } = resolvePolicies(branch)
|
|
17
|
+
if (!isPublic) continue
|
|
18
|
+
if (visibility === 'hidden') continue
|
|
19
|
+
result[branch.alias] = visibility === 'masked' && typeof value === 'string' && value.length > 0
|
|
20
|
+
? '••••••••'
|
|
21
|
+
: value
|
|
22
|
+
}
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
|
|
27
|
+
const projected = applyPublicPolicies(data, seed)
|
|
28
|
+
const requestedFields = (fieldsParam ?? '').split(',').map(f => f.trim()).filter(Boolean)
|
|
29
|
+
|
|
30
|
+
if (requestedFields.length === 0) return projected
|
|
31
|
+
|
|
32
|
+
const filtered: Record<string, unknown> = {}
|
|
33
|
+
for (const field of requestedFields) {
|
|
34
|
+
if (field in projected) filtered[field] = projected[field]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const key of IDENTITY_FIELDS) {
|
|
38
|
+
if (key in projected && !filtered[key]) filtered[key] = projected[key]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return filtered
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sha256hex } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
export function parseIdempotencyKey(rawValue: string | undefined): string | null {
|
|
4
|
+
if (!rawValue) return null
|
|
5
|
+
const key = rawValue.trim()
|
|
6
|
+
if (!key || key.length > 128) return null
|
|
7
|
+
return key
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type FingerprintInput = {
|
|
11
|
+
seedSlug: string
|
|
12
|
+
statusValue: unknown
|
|
13
|
+
slug: string | null
|
|
14
|
+
data: Record<string, unknown>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildRequestFingerprint(input: FingerprintInput): Promise<string> {
|
|
18
|
+
return sha256hex(JSON.stringify({ seedSlug: input.seedSlug, statusValue: input.statusValue, slug: input.slug, data: input.data }))
|
|
19
|
+
}
|
|
@@ -28,6 +28,11 @@ function normalizeProblemType(type: string): string {
|
|
|
28
28
|
return `https://beechcms.dev/problems/${type}`
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export function internalErrorDetail(env: { ENV?: string }, error: unknown): string {
|
|
32
|
+
if (env.ENV !== 'production' && error instanceof Error) return error.message
|
|
33
|
+
return 'An unexpected error occurred.'
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
/**
|
|
32
37
|
* Restituisce errori API in formato Problem Details (RFC 9457).
|
|
33
38
|
*/
|
package/src/public/public-add.ts
CHANGED
|
@@ -1,166 +1,110 @@
|
|
|
1
|
-
import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
|
|
2
|
-
import type { Context } from 'hono'
|
|
3
|
-
import { cleanStr } from '../shared/query-utils'
|
|
4
|
-
import { checkPublicOperation } from './access-policy'
|
|
5
|
-
import { publicProblem } from './problem-details'
|
|
6
|
-
import { generateEntrySlug, slugify } from './slug-utils'
|
|
7
|
-
import { sanitizePublicPayload } from './sanitize'
|
|
8
|
-
import {
|
|
9
|
-
import { AppEnv } from '../types'
|
|
10
|
-
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (idempotencyKey) {
|
|
112
|
-
const existing = await idempotencyRepository.lookup(idempotencyKey)
|
|
113
|
-
if (existing && existing.expiresAt >= now) {
|
|
114
|
-
if (existing.fingerprint !== fingerprint) {
|
|
115
|
-
return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
|
|
116
|
-
}
|
|
117
|
-
let parsedBody: unknown = null
|
|
118
|
-
try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
|
|
119
|
-
return context.json(parsedBody, existing.responseStatus as 201)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const id = crypto.randomUUID()
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
|
|
127
|
-
} catch (error) {
|
|
128
|
-
if (error instanceof SlugConflictError) {
|
|
129
|
-
return publicProblem(context, {
|
|
130
|
-
type: 'slug-conflict',
|
|
131
|
-
title: 'Conflict',
|
|
132
|
-
status: 409,
|
|
133
|
-
detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.`
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
throw error
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const responseBody = { success: true, id, slug: finalSlug }
|
|
140
|
-
if (idempotencyKey) {
|
|
141
|
-
await idempotencyRepository.store({
|
|
142
|
-
key: idempotencyKey,
|
|
143
|
-
fingerprint,
|
|
144
|
-
responseStatus: 201,
|
|
145
|
-
responseBody: JSON.stringify(responseBody),
|
|
146
|
-
expiresAt: now + idempotencyTtlSeconds
|
|
147
|
-
})
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
await createNotification(context, {
|
|
151
|
-
title: `${seed.label}: New entry`,
|
|
152
|
-
message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
|
|
153
|
-
type: 'success',
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
return context.json(responseBody, 201)
|
|
157
|
-
} catch (error) {
|
|
158
|
-
console.error('Public add error:', error)
|
|
159
|
-
return publicProblem(context, {
|
|
160
|
-
type: 'internal-server-error',
|
|
161
|
-
title: 'Internal Server Error',
|
|
162
|
-
status: 500,
|
|
163
|
-
detail: errorMessage(context, error)
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
}
|
|
1
|
+
import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import { cleanStr } from '../shared/query-utils'
|
|
4
|
+
import { checkPublicOperation } from './access-policy'
|
|
5
|
+
import { publicProblem, internalErrorDetail } from './problem-details'
|
|
6
|
+
import { generateEntrySlug, slugify } from './slug-utils'
|
|
7
|
+
import { sanitizePublicPayload } from './sanitize'
|
|
8
|
+
import { parseIdempotencyKey, buildRequestFingerprint } from './idempotency'
|
|
9
|
+
import { AppEnv } from '../types'
|
|
10
|
+
|
|
11
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
? (value as Record<string, unknown>)
|
|
14
|
+
: null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pickSlug(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
|
|
18
|
+
const explicit = cleanStr(body.slug)
|
|
19
|
+
if (explicit) return slugify(explicit)
|
|
20
|
+
return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function publicAddHandler(context: Context<AppEnv>) {
|
|
24
|
+
const seedSlug = context.req.param('seed') ?? ''
|
|
25
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
26
|
+
if (!seed) {
|
|
27
|
+
return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const access = checkPublicOperation(seed, 'add')
|
|
31
|
+
if (!access.ok) {
|
|
32
|
+
return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let body: Record<string, unknown>
|
|
36
|
+
try {
|
|
37
|
+
const parsed = await context.req.json<unknown>()
|
|
38
|
+
body = asRecord(parsed) ?? {}
|
|
39
|
+
} catch {
|
|
40
|
+
return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rawData = asRecord(body.data)
|
|
44
|
+
if (!rawData || Object.keys(rawData).length === 0) {
|
|
45
|
+
return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statusValue = body.status ?? 'draft'
|
|
49
|
+
if (!isValidContentStatus(statusValue)) {
|
|
50
|
+
return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sanitized = sanitizePublicPayload(seed, rawData, { operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true })
|
|
54
|
+
if (!sanitized.ok) {
|
|
55
|
+
if (sanitized.status === 422) {
|
|
56
|
+
return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
|
|
57
|
+
}
|
|
58
|
+
return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
|
|
62
|
+
const finalSlug = pickSlug(body, sanitized.data) || context.get('idGenerator').uuid().slice(0, 8)
|
|
63
|
+
const repository = context.get('repository')
|
|
64
|
+
const idempotencyRepository = context.get('idempotencyRepository')
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const now = Math.floor(Date.now() / 1000)
|
|
68
|
+
const fingerprint = await buildRequestFingerprint({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
|
|
69
|
+
const idempotencyTtl = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
|
|
70
|
+
|
|
71
|
+
if (idempotencyKey) {
|
|
72
|
+
const existing = await idempotencyRepository.lookup(idempotencyKey)
|
|
73
|
+
if (existing && existing.expiresAt >= now) {
|
|
74
|
+
if (existing.fingerprint !== fingerprint) {
|
|
75
|
+
return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
|
|
76
|
+
}
|
|
77
|
+
let parsedBody: unknown = null
|
|
78
|
+
try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
|
|
79
|
+
return context.json(parsedBody, existing.responseStatus as 201)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const id = context.get('idGenerator').uuid()
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof SlugConflictError) {
|
|
89
|
+
return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
|
|
90
|
+
}
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const responseBody = { success: true, id, slug: finalSlug }
|
|
95
|
+
if (idempotencyKey) {
|
|
96
|
+
await idempotencyRepository.store({ key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), expiresAt: now + idempotencyTtl })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
context.get('notificationService').notify({
|
|
100
|
+
title: `${seed.label}: New entry`,
|
|
101
|
+
message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
|
|
102
|
+
type: 'success',
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return context.json(responseBody, 201)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Public add error:', error)
|
|
108
|
+
return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: internalErrorDetail(context.env, error) })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -6,7 +6,6 @@ import { checkPublicOperation } from './access-policy'
|
|
|
6
6
|
import { publicProblem } from './problem-details'
|
|
7
7
|
import { slugify } from './slug-utils'
|
|
8
8
|
import { sanitizePublicPayload } from './sanitize'
|
|
9
|
-
import { createNotification } from '../shared/notification-service'
|
|
10
9
|
import { AppEnv } from '../types'
|
|
11
10
|
|
|
12
11
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
@@ -77,7 +76,9 @@ function resolveData(
|
|
|
77
76
|
|
|
78
77
|
const sensitiveAliases = Object.keys(rawData).filter((alias) => {
|
|
79
78
|
const branch = seed.branches.find((b) => b.alias === alias)
|
|
80
|
-
|
|
79
|
+
if (!branch) return false
|
|
80
|
+
const policies = resolvePolicies(branch)
|
|
81
|
+
return policies.privacy !== 'plain' || policies.public === false
|
|
81
82
|
})
|
|
82
83
|
if (sensitiveAliases.length > 0) {
|
|
83
84
|
return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
|
|
@@ -141,7 +142,7 @@ export async function publicEditHandler(context: PublicCtx) {
|
|
|
141
142
|
|
|
142
143
|
await repository.update(seed, id, updateData, statusResult.value)
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
context.get('notificationService').notify({
|
|
145
146
|
title: `${seed.label}: Update`,
|
|
146
147
|
message: `The entry "${slugResult.value.nextSlug}" has been modified via the public API.`,
|
|
147
148
|
type: 'info',
|