@actuate-media/cms-core 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__tests__/actions/document-crud.test.js +1 -1
  3. package/dist/__tests__/actions/document-crud.test.js.map +1 -1
  4. package/dist/__tests__/scheduling/scheduling.test.js +1 -1
  5. package/dist/__tests__/scheduling/scheduling.test.js.map +1 -1
  6. package/dist/__tests__/security/access.test.js +1 -1
  7. package/dist/__tests__/security/access.test.js.map +1 -1
  8. package/dist/__tests__/security/reauth.test.js +1 -1
  9. package/dist/__tests__/security/reauth.test.js.map +1 -1
  10. package/dist/__tests__/security/sanitize.test.js +1 -1
  11. package/dist/__tests__/security/sanitize.test.js.map +1 -1
  12. package/dist/__tests__/webhooks/webhooks.test.js +2 -2
  13. package/dist/__tests__/webhooks/webhooks.test.js.map +1 -1
  14. package/dist/actions.js +4 -4
  15. package/dist/actions.js.map +1 -1
  16. package/dist/api/handler-factory.d.ts.map +1 -1
  17. package/dist/api/handler-factory.js +26 -7
  18. package/dist/api/handler-factory.js.map +1 -1
  19. package/dist/api/handlers.d.ts +1 -1
  20. package/dist/api/handlers.d.ts.map +1 -1
  21. package/dist/api/handlers.js +339 -75
  22. package/dist/api/handlers.js.map +1 -1
  23. package/dist/api/index.d.ts +3 -3
  24. package/dist/api/index.d.ts.map +1 -1
  25. package/dist/api/index.js +2 -2
  26. package/dist/api/index.js.map +1 -1
  27. package/dist/auth/index.d.ts +10 -10
  28. package/dist/auth/index.d.ts.map +1 -1
  29. package/dist/auth/index.js +8 -8
  30. package/dist/auth/index.js.map +1 -1
  31. package/dist/auth/oauth.d.ts +1 -1
  32. package/dist/auth/oauth.d.ts.map +1 -1
  33. package/dist/auth/oauth.js +1 -1
  34. package/dist/auth/oauth.js.map +1 -1
  35. package/dist/auth/password.d.ts +2 -2
  36. package/dist/auth/password.d.ts.map +1 -1
  37. package/dist/auth/password.js +1 -1
  38. package/dist/auth/password.js.map +1 -1
  39. package/dist/auth/providers/github.d.ts +1 -1
  40. package/dist/auth/providers/github.d.ts.map +1 -1
  41. package/dist/auth/providers/google.d.ts +1 -1
  42. package/dist/auth/providers/google.d.ts.map +1 -1
  43. package/dist/auth/providers/microsoft.d.ts +1 -1
  44. package/dist/auth/providers/microsoft.d.ts.map +1 -1
  45. package/dist/cache/index.d.ts +1 -1
  46. package/dist/cache/index.d.ts.map +1 -1
  47. package/dist/codegen/index.d.ts.map +1 -1
  48. package/dist/codegen/index.js +2 -2
  49. package/dist/codegen/index.js.map +1 -1
  50. package/dist/collections/index.d.ts +1 -1
  51. package/dist/collections/index.d.ts.map +1 -1
  52. package/dist/config/define.d.ts +8 -0
  53. package/dist/config/define.d.ts.map +1 -0
  54. package/dist/config/define.js +7 -0
  55. package/dist/config/define.js.map +1 -0
  56. package/dist/config/index.d.ts +3 -3
  57. package/dist/config/index.d.ts.map +1 -1
  58. package/dist/config/index.js +1 -1
  59. package/dist/config/index.js.map +1 -1
  60. package/dist/config/types.d.ts +25 -3
  61. package/dist/config/types.d.ts.map +1 -1
  62. package/dist/content/index.d.ts +7 -7
  63. package/dist/content/index.d.ts.map +1 -1
  64. package/dist/content/index.js +4 -4
  65. package/dist/content/index.js.map +1 -1
  66. package/dist/db/adapters/mysql.js +1 -1
  67. package/dist/db/adapters/mysql.js.map +1 -1
  68. package/dist/db/adapters/postgres.js +1 -1
  69. package/dist/db/adapters/postgres.js.map +1 -1
  70. package/dist/db/adapters/sqlite.js +1 -1
  71. package/dist/db/adapters/sqlite.js.map +1 -1
  72. package/dist/fields/index.d.ts +1 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/forms/index.d.ts +4 -4
  75. package/dist/forms/index.d.ts.map +1 -1
  76. package/dist/forms/index.js +2 -2
  77. package/dist/forms/index.js.map +1 -1
  78. package/dist/graphql/index.d.ts +1 -1
  79. package/dist/graphql/index.d.ts.map +1 -1
  80. package/dist/graphql/index.js +4 -4
  81. package/dist/graphql/index.js.map +1 -1
  82. package/dist/i18n/index.d.ts +1 -1
  83. package/dist/i18n/index.d.ts.map +1 -1
  84. package/dist/index.d.ts +72 -72
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +40 -40
  87. package/dist/index.js.map +1 -1
  88. package/dist/media/index.d.ts +2 -2
  89. package/dist/media/index.d.ts.map +1 -1
  90. package/dist/media/index.js +1 -1
  91. package/dist/media/index.js.map +1 -1
  92. package/dist/middleware.d.ts +10 -2
  93. package/dist/middleware.d.ts.map +1 -1
  94. package/dist/middleware.js +1 -1
  95. package/dist/middleware.js.map +1 -1
  96. package/dist/next/preview.js +1 -1
  97. package/dist/next/preview.js.map +1 -1
  98. package/dist/next.d.ts +2 -2
  99. package/dist/next.d.ts.map +1 -1
  100. package/dist/next.js +33 -1
  101. package/dist/next.js.map +1 -1
  102. package/dist/search/index.js +1 -1
  103. package/dist/search/index.js.map +1 -1
  104. package/dist/security/access.d.ts +1 -1
  105. package/dist/security/access.d.ts.map +1 -1
  106. package/dist/security/audit.js +2 -2
  107. package/dist/security/audit.js.map +1 -1
  108. package/dist/security/captcha.d.ts +32 -0
  109. package/dist/security/captcha.d.ts.map +1 -0
  110. package/dist/security/captcha.js +101 -0
  111. package/dist/security/captcha.js.map +1 -0
  112. package/dist/security/index.d.ts +32 -30
  113. package/dist/security/index.d.ts.map +1 -1
  114. package/dist/security/index.js +20 -19
  115. package/dist/security/index.js.map +1 -1
  116. package/dist/security/middleware.d.ts +2 -2
  117. package/dist/security/middleware.d.ts.map +1 -1
  118. package/dist/security/middleware.js +2 -2
  119. package/dist/security/middleware.js.map +1 -1
  120. package/dist/security/reauth.js +2 -2
  121. package/dist/security/reauth.js.map +1 -1
  122. package/dist/seo/index.d.ts +8 -8
  123. package/dist/seo/index.d.ts.map +1 -1
  124. package/dist/seo/index.js +4 -4
  125. package/dist/seo/index.js.map +1 -1
  126. package/dist/setup/index.js +1 -1
  127. package/dist/setup/index.js.map +1 -1
  128. package/dist/upgrade/index.d.ts +6 -6
  129. package/dist/upgrade/index.d.ts.map +1 -1
  130. package/dist/upgrade/index.js +3 -3
  131. package/dist/upgrade/index.js.map +1 -1
  132. package/dist/upgrade/upgrade-pr.d.ts +1 -1
  133. package/dist/upgrade/upgrade-pr.d.ts.map +1 -1
  134. package/dist/upgrade/upgrade-pr.js +107 -17
  135. package/dist/upgrade/upgrade-pr.js.map +1 -1
  136. package/dist/upgrade/version-check.d.ts +10 -2
  137. package/dist/upgrade/version-check.d.ts.map +1 -1
  138. package/dist/upgrade/version-check.js +57 -11
  139. package/dist/upgrade/version-check.js.map +1 -1
  140. package/dist/webhooks/index.js +2 -2
  141. package/dist/webhooks/index.js.map +1 -1
  142. package/dist/workflow/index.js +1 -1
  143. package/dist/workflow/index.js.map +1 -1
  144. package/package.json +21 -13
  145. package/prisma/cms-schema.prisma +237 -0
  146. package/prisma/migrations/0001_init/migration.sql +384 -0
  147. package/prisma/migrations/0002_folders/migration.sql +39 -0
  148. package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -0
  149. package/prisma/migrations/migration_lock.toml +3 -0
  150. package/prisma/schema.prisma +485 -0
  151. package/prisma/seed.ts +82 -0
@@ -1,18 +1,28 @@
1
- import { listDocuments, getDocument, createDocument, updateDocument, deleteDocument, getGlobal, updateGlobal, } from '../actions';
2
- import { verifyPassword } from '../auth/password';
3
- import { createSession, verifySession, revokeSession } from '../auth/session';
4
- import { checkSetupRequired, createInitialAdmin } from '../setup/index';
5
- import { getDB } from '../db';
6
- import { generateCodeVerifier, generateCodeChallenge, generateState, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth';
7
- import { optimizeImage, formatBytes } from '../media/optimize';
8
- import { generateToken as generateCsrfToken } from '../security/csrf';
9
- import { logEvent } from '../security/audit';
10
- import { applyFieldAccess } from '../security/access';
11
- import { createPreviewAdapter } from '../preview/index';
12
- import { schedulingCronHandler } from '../scheduling/index';
13
- import { createRateLimiter } from '../security/rate-limit';
14
- import { generateOpenAPISpec } from './openapi';
15
- import { createSSEPresenceAdapter } from '../presence/index';
1
+ import { listDocuments, getDocument, createDocument, updateDocument, deleteDocument, getGlobal, updateGlobal, } from '../actions.js';
2
+ import { verifyPassword } from '../auth/password.js';
3
+ import { createSession, verifySession, revokeSession } from '../auth/session.js';
4
+ import { checkSetupRequired, createInitialAdmin } from '../setup/index.js';
5
+ import { getDB } from '../db.js';
6
+ import { generateCodeVerifier, generateCodeChallenge, generateState, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth.js';
7
+ import { optimizeImage, formatBytes } from '../media/optimize.js';
8
+ import { generateToken as generateCsrfToken } from '../security/csrf.js';
9
+ import { logEvent } from '../security/audit.js';
10
+ import { applyFieldAccess } from '../security/access.js';
11
+ import { createPreviewAdapter } from '../preview/index.js';
12
+ import { schedulingCronHandler } from '../scheduling/index.js';
13
+ import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
14
+ import { checkForUpdates } from '../upgrade/version-check.js';
15
+ import { createUpgradePR } from '../upgrade/upgrade-pr.js';
16
+ import { encryptField, decryptField } from '../security/encrypted-fields.js';
17
+ import { createRateLimiter } from '../security/rate-limit.js';
18
+ import { generateOpenAPISpec } from './openapi.js';
19
+ import { createSSEPresenceAdapter } from '../presence/index.js';
20
+ // Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
21
+ // Returns { put, del, ... } from @vercel/blob when available.
22
+ async function importBlobStorage() {
23
+ const mod = '@vercel/' + 'blob';
24
+ return import(/* webpackIgnore: true */ mod);
25
+ }
16
26
  const SECURITY_HEADERS = {
17
27
  'Content-Type': 'application/json',
18
28
  'X-Content-Type-Options': 'nosniff',
@@ -29,6 +39,9 @@ function errorResponse(message, status) {
29
39
  return json({ error: message }, status);
30
40
  }
31
41
  function internalError(err, context) {
42
+ if (err instanceof ModelNotAvailableError) {
43
+ return modelNotAvailable(err.model);
44
+ }
32
45
  const msg = err instanceof Error ? err.message : String(err);
33
46
  console.error(`[actuate][api]${context ? ` ${context}:` : ''} ${msg}`);
34
47
  return errorResponse('Internal server error', 500);
@@ -37,6 +50,38 @@ function clampPageSize(raw, max = 100, fallback = 20) {
37
50
  const n = Number(raw) || fallback;
38
51
  return Math.min(Math.max(1, n), max);
39
52
  }
53
+ function hasModel(d, name) {
54
+ try {
55
+ return d[name] && typeof d[name].findMany === 'function';
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ function modelNotAvailable(name) {
62
+ return errorResponse(`The "${name}" model is not available in your Prisma schema. `
63
+ + 'See https://actuatecms.dev/docs/database-setup for required models.', 501);
64
+ }
65
+ async function safeCount(model, where) {
66
+ try {
67
+ if (!model?.count)
68
+ return 0;
69
+ return await model.count(where ? { where } : undefined);
70
+ }
71
+ catch {
72
+ return 0;
73
+ }
74
+ }
75
+ async function safeFindMany(model, args) {
76
+ try {
77
+ if (!model?.findMany)
78
+ return [];
79
+ return await model.findMany(args);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
40
85
  function isAllowedStorageUrl(url) {
41
86
  try {
42
87
  const parsed = new URL(url);
@@ -63,11 +108,18 @@ const ALLOWED_SORT_FIELDS = new Set([
63
108
  'createdAt', 'updatedAt', 'publishedAt', 'status', 'collection',
64
109
  ]);
65
110
  function getSessionSecret() {
66
- const secret = process.env.CMS_SECRET;
67
- if (!secret)
68
- throw new Error('CMS_SECRET environment variable is not set');
69
- if (secret.length < 32)
70
- throw new Error('CMS_SECRET must be at least 32 characters');
111
+ const secret = process.env.CMS_SECRET
112
+ ?? process.env.CMS_SESSION_SECRET
113
+ ?? globalThis.__actuateConfig?.secret;
114
+ if (!secret) {
115
+ throw new Error('[Actuate CMS] Missing CMS secret. Set the CMS_SECRET environment variable (min 32 characters) '
116
+ + 'or pass `secret` in your actuate.config.ts. '
117
+ + 'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
118
+ }
119
+ if (secret.length < 32) {
120
+ throw new Error('[Actuate CMS] CMS secret must be at least 32 characters (got ' + secret.length + '). '
121
+ + 'Generate a secure value with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
122
+ }
71
123
  return secret;
72
124
  }
73
125
  async function extractSession(request) {
@@ -85,13 +137,15 @@ async function extractSession(request) {
85
137
  return null;
86
138
  try {
87
139
  const payload = await verifySession(token, { secret: getSessionSecret() });
88
- // Verify session is not revoked in DB
89
- const dbSession = await getDB().session.findUnique({
90
- where: { id: payload.sessionId },
91
- select: { revokedAt: true },
92
- });
93
- if (!dbSession || dbSession.revokedAt)
94
- return null;
140
+ const d = getDB();
141
+ if (hasModel(d, 'session')) {
142
+ const dbSession = await d.session.findUnique({
143
+ where: { id: payload.sessionId },
144
+ select: { revokedAt: true },
145
+ });
146
+ if (!dbSession || dbSession.revokedAt)
147
+ return null;
148
+ }
95
149
  return payload;
96
150
  }
97
151
  catch {
@@ -137,8 +191,35 @@ async function checkRateLimitAsync(limiter, key) {
137
191
  const result = await limiter.check(key);
138
192
  return result.allowed;
139
193
  }
194
+ function getAdminPath() {
195
+ return process.env.ACTUATE_ADMIN_PATH
196
+ ?? globalThis.__actuateConfig?.admin?.path
197
+ ?? '/admin';
198
+ }
199
+ class ModelNotAvailableError extends Error {
200
+ model;
201
+ constructor(model) {
202
+ super(`Model "${model}" is not available in the Prisma schema.`);
203
+ this.model = model;
204
+ }
205
+ }
140
206
  export function registerCMSRoutes(router) {
141
- const db = () => getDB();
207
+ const rawDb = () => getDB();
208
+ const db = () => {
209
+ const d = rawDb();
210
+ return new Proxy(d, {
211
+ get(target, prop) {
212
+ const val = target[prop];
213
+ if (val !== undefined)
214
+ return val;
215
+ return new Proxy({}, {
216
+ get() {
217
+ throw new ModelNotAvailableError(String(prop));
218
+ },
219
+ });
220
+ },
221
+ });
222
+ };
142
223
  const presenceAdapter = createSSEPresenceAdapter();
143
224
  // ---------------------------------------------------------------------------
144
225
  // CSRF token endpoint
@@ -203,7 +284,17 @@ export function registerCMSRoutes(router) {
203
284
  if (!(await checkRateLimitAsync(loginLimiter, `login:${clientIp}`))) {
204
285
  return errorResponse('Too many login attempts. Please try again later.', 429);
205
286
  }
206
- const user = await db().user.findFirst({
287
+ const captchaConfig = getCaptchaConfig();
288
+ if (captchaConfig.provider !== 'none') {
289
+ const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, clientIp);
290
+ if (!captchaResult.success) {
291
+ return errorResponse('CAPTCHA verification failed. Please try again.', 403);
292
+ }
293
+ }
294
+ const d = db();
295
+ if (!hasModel(d, 'user'))
296
+ return modelNotAvailable('user');
297
+ const user = await d.user.findFirst({
207
298
  where: { email: email.toLowerCase().trim() },
208
299
  });
209
300
  if (!user) {
@@ -276,7 +367,7 @@ export function registerCMSRoutes(router) {
276
367
  return response;
277
368
  }
278
369
  catch (err) {
279
- return errorResponse('Login failed', 500);
370
+ return internalError(err, 'login');
280
371
  }
281
372
  });
282
373
  router.post('/auth/logout', async (request) => {
@@ -295,7 +386,7 @@ export function registerCMSRoutes(router) {
295
386
  return response;
296
387
  }
297
388
  catch (err) {
298
- return errorResponse('Logout failed', 500);
389
+ return internalError(err, 'logout');
299
390
  }
300
391
  });
301
392
  router.get('/auth/me', async (request) => {
@@ -313,7 +404,7 @@ export function registerCMSRoutes(router) {
313
404
  return json({ data: user });
314
405
  }
315
406
  catch (err) {
316
- return errorResponse('Failed to fetch user', 500);
407
+ return internalError(err, 'auth/me');
317
408
  }
318
409
  });
319
410
  // ---------------------------------------------------------------------------
@@ -324,7 +415,7 @@ export function registerCMSRoutes(router) {
324
415
  const auth = await requireAuth(request);
325
416
  if (auth.error)
326
417
  return auth.error;
327
- const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../auth/totp');
418
+ const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../auth/totp.js');
328
419
  const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { email: true, totpEnabled: true } });
329
420
  if (!user)
330
421
  return errorResponse('User not found', 404);
@@ -348,7 +439,7 @@ export function registerCMSRoutes(router) {
348
439
  const body = await request.json();
349
440
  if (!body.code)
350
441
  return errorResponse('Code is required', 400);
351
- const { verifyTOTP } = await import('../auth/totp');
442
+ const { verifyTOTP } = await import('../auth/totp.js');
352
443
  const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { totpSecret: true } });
353
444
  if (!user?.totpSecret)
354
445
  return errorResponse('TOTP not set up', 400);
@@ -379,7 +470,7 @@ export function registerCMSRoutes(router) {
379
470
  const body = await request.json();
380
471
  if (!body.userId || !body.code)
381
472
  return errorResponse('userId and code are required', 400);
382
- const { verifyTOTP } = await import('../auth/totp');
473
+ const { verifyTOTP } = await import('../auth/totp.js');
383
474
  const user = await db().user.findUnique({ where: { id: body.userId }, select: { id: true, email: true, role: true, totpSecret: true, totpEnabled: true, isActive: true } });
384
475
  if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret)
385
476
  return errorResponse('Invalid request', 400);
@@ -422,7 +513,7 @@ export function registerCMSRoutes(router) {
422
513
  };
423
514
  const codeVerifier = generateCodeVerifier();
424
515
  const codeChallenge = await generateCodeChallenge(codeVerifier);
425
- const state = await generateState(provider, codeVerifier, '/admin', secret);
516
+ const state = await generateState(provider, codeVerifier, getAdminPath(), secret);
426
517
  const url = getAuthorizationUrl(provider, oauthProviders[provider], state, codeChallenge);
427
518
  return json({ data: { url } });
428
519
  }
@@ -444,7 +535,7 @@ export function registerCMSRoutes(router) {
444
535
  const desc = url.searchParams.get('error_description') ?? errorParam;
445
536
  return new Response(null, {
446
537
  status: 302,
447
- headers: { Location: `/admin/login?error=${encodeURIComponent(desc)}` },
538
+ headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(desc)}` },
448
539
  });
449
540
  }
450
541
  if (!code || !stateToken) {
@@ -473,7 +564,7 @@ export function registerCMSRoutes(router) {
473
564
  return new Response(null, {
474
565
  status: 302,
475
566
  headers: {
476
- Location: '/admin',
567
+ Location: getAdminPath(),
477
568
  'Set-Cookie': cookieFlags.join('; '),
478
569
  },
479
570
  });
@@ -482,7 +573,7 @@ export function registerCMSRoutes(router) {
482
573
  const message = err instanceof Error ? err.message : 'OAuth callback failed';
483
574
  return new Response(null, {
484
575
  status: 302,
485
- headers: { Location: `/admin/login?error=${encodeURIComponent(message)}` },
576
+ headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(message)}` },
486
577
  });
487
578
  }
488
579
  });
@@ -572,7 +663,7 @@ export function registerCMSRoutes(router) {
572
663
  return json({ data: doc }, 201);
573
664
  }
574
665
  catch (err) {
575
- return errorResponse('Failed to create document', 500);
666
+ return internalError(err, 'create document');
576
667
  }
577
668
  });
578
669
  router.put('/collections/:slug/:id', async (request, params) => {
@@ -594,7 +685,7 @@ export function registerCMSRoutes(router) {
594
685
  return json({ data: doc });
595
686
  }
596
687
  catch (err) {
597
- return errorResponse('Failed to update document', 500);
688
+ return internalError(err, 'update document');
598
689
  }
599
690
  });
600
691
  router.delete('/collections/:slug/:id', async (request, params) => {
@@ -615,7 +706,7 @@ export function registerCMSRoutes(router) {
615
706
  return json({ data: { success: true } });
616
707
  }
617
708
  catch (err) {
618
- return errorResponse('Failed to delete document', 500);
709
+ return internalError(err, 'delete document');
619
710
  }
620
711
  });
621
712
  // ---------------------------------------------------------------------------
@@ -721,8 +812,7 @@ export function registerCMSRoutes(router) {
721
812
  const storageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
722
813
  let publicUrl = '';
723
814
  try {
724
- // @ts-ignore -- @vercel/blob is an optional peer dependency
725
- const blob = await import('@vercel/blob');
815
+ const blob = await importBlobStorage();
726
816
  const result = await blob.put(storageKey, uploadBuffer, {
727
817
  access: 'public',
728
818
  contentType: finalMimeType,
@@ -808,8 +898,7 @@ export function registerCMSRoutes(router) {
808
898
  const newStorageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
809
899
  let newPublicUrl = '';
810
900
  try {
811
- // @ts-ignore -- @vercel/blob is an optional peer dependency
812
- const blob = await import('@vercel/blob');
901
+ const blob = await importBlobStorage();
813
902
  const uploadResult = await blob.put(newStorageKey, result.buffer, {
814
903
  access: 'public',
815
904
  contentType: result.mimeType,
@@ -893,8 +982,7 @@ export function registerCMSRoutes(router) {
893
982
  return errorResponse('Media not found', 404);
894
983
  }
895
984
  try {
896
- // @ts-ignore -- @vercel/blob is an optional peer dependency
897
- const blob = await import('@vercel/blob');
985
+ const blob = await importBlobStorage();
898
986
  await blob.del(media.storageKey);
899
987
  }
900
988
  catch {
@@ -915,6 +1003,172 @@ export function registerCMSRoutes(router) {
915
1003
  // ---------------------------------------------------------------------------
916
1004
  // Setup routes
917
1005
  // ---------------------------------------------------------------------------
1006
+ // ---------------------------------------------------------------------------
1007
+ // Update routes
1008
+ // ---------------------------------------------------------------------------
1009
+ const UPDATE_CONFIG_KEY = '_cms_update_config';
1010
+ async function getUpdateConfig() {
1011
+ const encKey = process.env.CMS_ENCRYPTION_KEY;
1012
+ if (!encKey)
1013
+ return {};
1014
+ try {
1015
+ const doc = await db().document.findFirst({
1016
+ where: { collection: UPDATE_CONFIG_KEY, deletedAt: null },
1017
+ });
1018
+ if (!doc?.data)
1019
+ return {};
1020
+ const raw = doc.data;
1021
+ const result = {};
1022
+ if (raw.githubToken)
1023
+ result.githubToken = await decryptField(raw.githubToken, encKey);
1024
+ if (raw.githubRepo)
1025
+ result.githubRepo = await decryptField(raw.githubRepo, encKey);
1026
+ return result;
1027
+ }
1028
+ catch {
1029
+ return {};
1030
+ }
1031
+ }
1032
+ router.get('/updates/check', async (request) => {
1033
+ try {
1034
+ const session = await extractSession(request);
1035
+ if (!session)
1036
+ return errorResponse('Unauthorized', 401);
1037
+ const coreVersion = globalThis.__actuateCoreVersion ?? '0.1.0';
1038
+ const info = await checkForUpdates(coreVersion);
1039
+ const saved = await getUpdateConfig();
1040
+ return json({
1041
+ data: {
1042
+ ...info,
1043
+ hasGithubToken: !!(saved.githubToken || process.env.ACTUATE_GITHUB_TOKEN),
1044
+ githubRepo: saved.githubRepo || process.env.ACTUATE_GITHUB_REPO || '',
1045
+ },
1046
+ });
1047
+ }
1048
+ catch (err) {
1049
+ return internalError(err);
1050
+ }
1051
+ });
1052
+ router.get('/updates/config', async (request) => {
1053
+ try {
1054
+ const session = await extractSession(request);
1055
+ if (!session || session.role !== 'admin') {
1056
+ return errorResponse('Unauthorized — admin only', 403);
1057
+ }
1058
+ const saved = await getUpdateConfig();
1059
+ return json({
1060
+ data: {
1061
+ hasGithubToken: !!(saved.githubToken || process.env.ACTUATE_GITHUB_TOKEN),
1062
+ githubRepo: saved.githubRepo || process.env.ACTUATE_GITHUB_REPO || '',
1063
+ },
1064
+ });
1065
+ }
1066
+ catch (err) {
1067
+ return internalError(err);
1068
+ }
1069
+ });
1070
+ router.put('/updates/config', async (request) => {
1071
+ try {
1072
+ const session = await extractSession(request);
1073
+ if (!session || session.role !== 'admin') {
1074
+ return errorResponse('Unauthorized — admin only', 403);
1075
+ }
1076
+ const encKey = process.env.CMS_ENCRYPTION_KEY;
1077
+ if (!encKey) {
1078
+ return errorResponse('CMS_ENCRYPTION_KEY is required to store encrypted credentials.', 400);
1079
+ }
1080
+ const body = await request.json();
1081
+ if (body.githubRepo && !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(body.githubRepo)) {
1082
+ return errorResponse('Invalid repository format. Use owner/repo.', 400);
1083
+ }
1084
+ const encrypted = {};
1085
+ if (body.githubToken)
1086
+ encrypted.githubToken = await encryptField(body.githubToken, encKey);
1087
+ if (body.githubRepo)
1088
+ encrypted.githubRepo = await encryptField(body.githubRepo, encKey);
1089
+ await db().document.upsert({
1090
+ where: { collection_slug: { collection: UPDATE_CONFIG_KEY, slug: UPDATE_CONFIG_KEY } },
1091
+ create: {
1092
+ collection: UPDATE_CONFIG_KEY,
1093
+ slug: UPDATE_CONFIG_KEY,
1094
+ title: 'Update Configuration',
1095
+ data: encrypted,
1096
+ status: 'PUBLISHED',
1097
+ createdById: session.userId,
1098
+ updatedById: session.userId,
1099
+ },
1100
+ update: {
1101
+ data: encrypted,
1102
+ updatedById: session.userId,
1103
+ },
1104
+ });
1105
+ await logEvent({
1106
+ event: 'settings_changed',
1107
+ userId: session.userId,
1108
+ details: { setting: 'update_config' },
1109
+ });
1110
+ return json({ data: { success: true } });
1111
+ }
1112
+ catch (err) {
1113
+ return internalError(err);
1114
+ }
1115
+ });
1116
+ router.post('/updates/apply', async (request) => {
1117
+ try {
1118
+ const session = await extractSession(request);
1119
+ if (!session || session.role !== 'admin') {
1120
+ return errorResponse('Unauthorized — admin only', 403);
1121
+ }
1122
+ const body = await request.json();
1123
+ if (!body.targetVersion) {
1124
+ return errorResponse('targetVersion is required', 400);
1125
+ }
1126
+ if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(body.targetVersion)) {
1127
+ return errorResponse('Invalid version format', 400);
1128
+ }
1129
+ const saved = await getUpdateConfig();
1130
+ const githubToken = saved.githubToken || process.env.ACTUATE_GITHUB_TOKEN;
1131
+ const githubRepo = saved.githubRepo || process.env.ACTUATE_GITHUB_REPO;
1132
+ const owner = githubRepo?.split('/')[0];
1133
+ const repo = githubRepo?.split('/')[1];
1134
+ if (!githubToken) {
1135
+ return errorResponse('GitHub token not configured. Go to Settings > Updates to add one.', 400);
1136
+ }
1137
+ if (!owner || !repo) {
1138
+ return errorResponse('GitHub repository not configured. Go to Settings > Updates to add one.', 400);
1139
+ }
1140
+ const coreVersion = globalThis.__actuateCoreVersion ?? '0.1.0';
1141
+ const result = await createUpgradePR({
1142
+ owner,
1143
+ repo,
1144
+ targetVersion: body.targetVersion,
1145
+ changesDescription: `Upgrade Actuate CMS from ${coreVersion} to ${body.targetVersion}.\n\nRun \`npx prisma migrate deploy\` after merging.`,
1146
+ githubToken,
1147
+ });
1148
+ await logEvent({
1149
+ event: 'cms_update_initiated',
1150
+ userId: session.userId,
1151
+ details: {
1152
+ fromVersion: coreVersion,
1153
+ toVersion: body.targetVersion,
1154
+ prUrl: result.prUrl,
1155
+ },
1156
+ });
1157
+ return json({ data: result });
1158
+ }
1159
+ catch (err) {
1160
+ return internalError(err);
1161
+ }
1162
+ });
1163
+ router.get('/captcha/config', async () => {
1164
+ const captchaConfig = getCaptchaConfig();
1165
+ return json({
1166
+ data: {
1167
+ provider: captchaConfig.provider,
1168
+ siteKey: captchaConfig.provider !== 'none' ? captchaConfig.siteKey : null,
1169
+ },
1170
+ });
1171
+ });
918
1172
  router.get('/setup/status', async () => {
919
1173
  try {
920
1174
  const status = await checkSetupRequired(db());
@@ -956,11 +1210,12 @@ export function registerCMSRoutes(router) {
956
1210
  const auth = await requireAuth(request);
957
1211
  if (auth.error)
958
1212
  return auth.error;
1213
+ const d = db();
959
1214
  const [totalDocuments, totalMedia, totalUsers, recentDocuments] = await Promise.all([
960
- db().document.count({ where: { deletedAt: null } }),
961
- db().media.count(),
962
- db().user.count(),
963
- db().document.findMany({
1215
+ safeCount(d.document, { deletedAt: null }),
1216
+ safeCount(d.media),
1217
+ safeCount(d.user),
1218
+ safeFindMany(d.document, {
964
1219
  where: { deletedAt: null },
965
1220
  orderBy: { updatedAt: 'desc' },
966
1221
  take: 10,
@@ -984,20 +1239,21 @@ export function registerCMSRoutes(router) {
984
1239
  const q = (url.searchParams.get('q') ?? '').trim();
985
1240
  if (!q)
986
1241
  return json({ data: { documents: [], media: [], users: [] } });
1242
+ const d = db();
987
1243
  const [documents, media, users] = await Promise.all([
988
- db().document.findMany({
1244
+ safeFindMany(d.document, {
989
1245
  where: { deletedAt: null, OR: [{ title: { contains: q, mode: 'insensitive' } }, { plainText: { contains: q, mode: 'insensitive' } }] },
990
1246
  take: 10,
991
1247
  orderBy: { updatedAt: 'desc' },
992
1248
  select: { id: true, title: true, slug: true, collection: true, status: true, updatedAt: true },
993
1249
  }),
994
- db().media.findMany({
1250
+ safeFindMany(d.media, {
995
1251
  where: { OR: [{ filename: { contains: q, mode: 'insensitive' } }, { altText: { contains: q, mode: 'insensitive' } }] },
996
1252
  take: 5,
997
1253
  orderBy: { createdAt: 'desc' },
998
1254
  select: { id: true, filename: true, altText: true, mimeType: true, storageKey: true },
999
1255
  }),
1000
- db().user.findMany({
1256
+ safeFindMany(d.user, {
1001
1257
  where: { isActive: true, OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] },
1002
1258
  take: 5,
1003
1259
  select: { id: true, name: true, email: true, role: true },
@@ -1124,6 +1380,14 @@ export function registerCMSRoutes(router) {
1124
1380
  if (!body.fields || typeof body.fields !== 'object') {
1125
1381
  return errorResponse('Missing or invalid "fields" in request body', 400);
1126
1382
  }
1383
+ const captchaConfig = getCaptchaConfig();
1384
+ if (captchaConfig.provider !== 'none') {
1385
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
1386
+ const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, ip);
1387
+ if (!captchaResult.success) {
1388
+ return errorResponse('CAPTCHA verification failed. Please try again.', 403);
1389
+ }
1390
+ }
1127
1391
  const submission = await db().formSubmission.create({
1128
1392
  data: {
1129
1393
  formId,
@@ -1218,7 +1482,7 @@ export function registerCMSRoutes(router) {
1218
1482
  return json({ data: redirect }, 201);
1219
1483
  }
1220
1484
  catch (err) {
1221
- return errorResponse('Failed to create redirect', 500);
1485
+ return internalError(err, 'create redirect');
1222
1486
  }
1223
1487
  });
1224
1488
  router.delete('/redirects/:id', async (request, params) => {
@@ -1232,7 +1496,7 @@ export function registerCMSRoutes(router) {
1232
1496
  return json({ data: { success: true } });
1233
1497
  }
1234
1498
  catch (err) {
1235
- return errorResponse('Failed to delete redirect', 500);
1499
+ return internalError(err, 'delete redirect');
1236
1500
  }
1237
1501
  });
1238
1502
  // ---------------------------------------------------------------------------
@@ -1369,7 +1633,7 @@ export function registerCMSRoutes(router) {
1369
1633
  const doc = await db().document.findUnique({ where: { id: params.documentId } });
1370
1634
  if (!doc)
1371
1635
  return errorResponse('Not found', 404);
1372
- const { analyzeContent } = await import('../seo/analysis');
1636
+ const { analyzeContent } = await import('../seo/analysis.js');
1373
1637
  const data = doc.data || {};
1374
1638
  const result = analyzeContent({
1375
1639
  title: doc.title || data.title || '',
@@ -1398,7 +1662,7 @@ export function registerCMSRoutes(router) {
1398
1662
  const doc = await db().document.findUnique({ where: { id: params.documentId } });
1399
1663
  if (!doc)
1400
1664
  return errorResponse('Not found', 404);
1401
- const { calculateReadability, stripHtmlTags } = await import('../seo/analysis');
1665
+ const { calculateReadability, stripHtmlTags } = await import('../seo/analysis.js');
1402
1666
  const data = doc.data || {};
1403
1667
  const text = stripHtmlTags(data.content || data.body || '');
1404
1668
  const result = calculateReadability(text);
@@ -1454,7 +1718,7 @@ export function registerCMSRoutes(router) {
1454
1718
  orderBy: { updatedAt: 'desc' },
1455
1719
  take: 50,
1456
1720
  });
1457
- const { generateLlmsTxt } = await import('../seo/llms-txt');
1721
+ const { generateLlmsTxt } = await import('../seo/llms-txt.js');
1458
1722
  const pages = docs.map((d) => {
1459
1723
  const data = d.data || {};
1460
1724
  return {
@@ -1487,7 +1751,7 @@ export function registerCMSRoutes(router) {
1487
1751
  const doc = await db().document.findUnique({ where: { id: params.documentId } });
1488
1752
  if (!doc)
1489
1753
  return errorResponse('Not found', 404);
1490
- const { buildSchemaGraph } = await import('../content/structured-data');
1754
+ const { buildSchemaGraph } = await import('../content/structured-data.js');
1491
1755
  const data = doc.data || {};
1492
1756
  const graph = buildSchemaGraph({
1493
1757
  siteName: 'Actuate CMS',
@@ -1516,7 +1780,7 @@ export function registerCMSRoutes(router) {
1516
1780
  const doc = await db().document.findUnique({ where: { id: params.documentId } });
1517
1781
  if (!doc)
1518
1782
  return errorResponse('Not found', 404);
1519
- const { generateMetaTags } = await import('../seo/meta-tags');
1783
+ const { generateMetaTags } = await import('../seo/meta-tags.js');
1520
1784
  const data = doc.data || {};
1521
1785
  const tags = generateMetaTags({
1522
1786
  title: data.metaTitle || doc.title || data.title || '',
@@ -1764,7 +2028,7 @@ export function registerCMSRoutes(router) {
1764
2028
  return json({ data: { token: session.token, expiresAt: session.expiresAt } });
1765
2029
  }
1766
2030
  catch (err) {
1767
- return errorResponse('Failed to create preview token', 500);
2031
+ return internalError(err, 'create preview token');
1768
2032
  }
1769
2033
  });
1770
2034
  router.get('/preview/:collection/:id', async (request, params) => {
@@ -1786,7 +2050,7 @@ export function registerCMSRoutes(router) {
1786
2050
  return json({ data });
1787
2051
  }
1788
2052
  catch (err) {
1789
- return errorResponse('Failed to fetch preview data', 500);
2053
+ return internalError(err, 'fetch preview data');
1790
2054
  }
1791
2055
  });
1792
2056
  // ---------------------------------------------------------------------------
@@ -1800,7 +2064,7 @@ export function registerCMSRoutes(router) {
1800
2064
  const body = await request.json();
1801
2065
  if (!body.stage)
1802
2066
  return errorResponse('Stage is required', 400);
1803
- const { transitionDocument } = await import('../workflow/index');
2067
+ const { transitionDocument } = await import('../workflow/index.js');
1804
2068
  const result = await transitionDocument(params.id, body.stage, auth.session.userId, auth.session.role, body.note);
1805
2069
  if (!result.success)
1806
2070
  return errorResponse(result.error ?? 'Transition failed', 400);
@@ -1815,7 +2079,7 @@ export function registerCMSRoutes(router) {
1815
2079
  const auth = await requireAuth(request);
1816
2080
  if (auth.error)
1817
2081
  return auth.error;
1818
- const { getAvailableTransitions } = await import('../workflow/index');
2082
+ const { getAvailableTransitions } = await import('../workflow/index.js');
1819
2083
  const doc = await db().document.findFirst({
1820
2084
  where: { id: params.id, deletedAt: null },
1821
2085
  select: { workflowStage: true, reviewerId: true, reviewNote: true },
@@ -1890,7 +2154,7 @@ export function registerCMSRoutes(router) {
1890
2154
  return json({ data: doc });
1891
2155
  }
1892
2156
  catch (err) {
1893
- return errorResponse('Failed to restore version', 500);
2157
+ return internalError(err, 'restore version');
1894
2158
  }
1895
2159
  });
1896
2160
  // ---------------------------------------------------------------------------
@@ -1908,7 +2172,7 @@ export function registerCMSRoutes(router) {
1908
2172
  return json({ data: result });
1909
2173
  }
1910
2174
  catch (err) {
1911
- return errorResponse('Scheduling run failed', 500);
2175
+ return internalError(err, 'scheduling run');
1912
2176
  }
1913
2177
  });
1914
2178
  router.get('/scheduling/calendar', async (request) => {
@@ -1921,7 +2185,7 @@ export function registerCMSRoutes(router) {
1921
2185
  const toStr = url.searchParams.get('to');
1922
2186
  const from = fromStr ? new Date(fromStr) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1923
2187
  const to = toStr ? new Date(toStr) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
1924
- const { getScheduleCalendar } = await import('../scheduling/index');
2188
+ const { getScheduleCalendar } = await import('../scheduling/index.js');
1925
2189
  const entries = await getScheduleCalendar(from, to, db());
1926
2190
  return json({ data: entries });
1927
2191
  }
@@ -1976,7 +2240,7 @@ export function registerCMSRoutes(router) {
1976
2240
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
1977
2241
  if (roleErr)
1978
2242
  return roleErr;
1979
- const { listEndpoints } = await import('../webhooks/index');
2243
+ const { listEndpoints } = await import('../webhooks/index.js');
1980
2244
  const endpoints = await listEndpoints();
1981
2245
  return json({ data: endpoints });
1982
2246
  }
@@ -1996,7 +2260,7 @@ export function registerCMSRoutes(router) {
1996
2260
  if (!body.url || !body.events?.length) {
1997
2261
  return errorResponse('url and events are required', 400);
1998
2262
  }
1999
- const { createEndpoint } = await import('../webhooks/index');
2263
+ const { createEndpoint } = await import('../webhooks/index.js');
2000
2264
  const secret = body.secret || crypto.randomUUID();
2001
2265
  const endpoint = await createEndpoint({
2002
2266
  url: body.url,
@@ -2025,7 +2289,7 @@ export function registerCMSRoutes(router) {
2025
2289
  if (roleErr)
2026
2290
  return roleErr;
2027
2291
  const body = await request.json();
2028
- const { updateEndpoint } = await import('../webhooks/index');
2292
+ const { updateEndpoint } = await import('../webhooks/index.js');
2029
2293
  const updated = await updateEndpoint(params.id, body);
2030
2294
  return json({ data: updated });
2031
2295
  }
@@ -2041,7 +2305,7 @@ export function registerCMSRoutes(router) {
2041
2305
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
2042
2306
  if (roleErr)
2043
2307
  return roleErr;
2044
- const { deleteEndpoint } = await import('../webhooks/index');
2308
+ const { deleteEndpoint } = await import('../webhooks/index.js');
2045
2309
  await deleteEndpoint(params.id);
2046
2310
  await logEvent({
2047
2311
  event: 'settings_changed',
@@ -2062,7 +2326,7 @@ export function registerCMSRoutes(router) {
2062
2326
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
2063
2327
  if (roleErr)
2064
2328
  return roleErr;
2065
- const { getDeliveries } = await import('../webhooks/index');
2329
+ const { getDeliveries } = await import('../webhooks/index.js');
2066
2330
  const deliveries = await getDeliveries(params.id);
2067
2331
  return json({ data: deliveries });
2068
2332
  }
@@ -2078,7 +2342,7 @@ export function registerCMSRoutes(router) {
2078
2342
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
2079
2343
  if (roleErr)
2080
2344
  return roleErr;
2081
- const { processRetries } = await import('../webhooks/index');
2345
+ const { processRetries } = await import('../webhooks/index.js');
2082
2346
  const result = await processRetries();
2083
2347
  return json({ data: result });
2084
2348
  }