@actuate-media/cms-core 0.13.0 → 0.15.0
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/LICENSE +21 -21
- package/dist/__tests__/api/api-key-auth.test.d.ts +2 -0
- package/dist/__tests__/api/api-key-auth.test.d.ts.map +1 -0
- package/dist/__tests__/api/api-key-auth.test.js +254 -0
- package/dist/__tests__/api/api-key-auth.test.js.map +1 -0
- package/dist/__tests__/api/public-seo.test.d.ts +2 -0
- package/dist/__tests__/api/public-seo.test.d.ts.map +1 -0
- package/dist/__tests__/api/public-seo.test.js +341 -0
- package/dist/__tests__/api/public-seo.test.js.map +1 -0
- package/dist/__tests__/security/api-key-enhanced.test.d.ts +2 -0
- package/dist/__tests__/security/api-key-enhanced.test.d.ts.map +1 -0
- package/dist/__tests__/security/api-key-enhanced.test.js +110 -0
- package/dist/__tests__/security/api-key-enhanced.test.js.map +1 -0
- package/dist/__tests__/seo/page-meta.test.d.ts +2 -0
- package/dist/__tests__/seo/page-meta.test.d.ts.map +1 -0
- package/dist/__tests__/seo/page-meta.test.js +204 -0
- package/dist/__tests__/seo/page-meta.test.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +20 -2
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +764 -31
- package/dist/api/handlers.js.map +1 -1
- package/dist/config/types.d.ts +75 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/security/api-key-enhanced.d.ts +48 -5
- package/dist/security/api-key-enhanced.d.ts.map +1 -1
- package/dist/security/api-key-enhanced.js +60 -9
- package/dist/security/api-key-enhanced.js.map +1 -1
- package/dist/seo/index.d.ts +2 -0
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +1 -0
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/page-meta.d.ts +79 -0
- package/dist/seo/page-meta.d.ts.map +1 -0
- package/dist/seo/page-meta.js +209 -0
- package/dist/seo/page-meta.js.map +1 -0
- package/generated/browser.ts +109 -0
- package/generated/client.ts +133 -0
- package/generated/commonInputTypes.ts +709 -0
- package/generated/enums.ts +125 -0
- package/generated/internal/class.ts +376 -0
- package/generated/internal/prismaNamespace.ts +2617 -0
- package/generated/internal/prismaNamespaceBrowser.ts +611 -0
- package/generated/models/ApiKey.ts +1550 -0
- package/generated/models/AuditLog.ts +1206 -0
- package/generated/models/BackupRecord.ts +1250 -0
- package/generated/models/ContentLock.ts +1472 -0
- package/generated/models/ContentTemplate.ts +1416 -0
- package/generated/models/Document.ts +3005 -0
- package/generated/models/Folder.ts +1904 -0
- package/generated/models/FormSubmission.ts +1200 -0
- package/generated/models/InAppNotification.ts +1457 -0
- package/generated/models/Media.ts +2340 -0
- package/generated/models/MediaUsage.ts +1472 -0
- package/generated/models/OAuthAccount.ts +1463 -0
- package/generated/models/Redirect.ts +1284 -0
- package/generated/models/Session.ts +1492 -0
- package/generated/models/Site.ts +1206 -0
- package/generated/models/User.ts +3513 -0
- package/generated/models/Version.ts +1511 -0
- package/generated/models/WorkflowState.ts +1514 -0
- package/generated/models.ts +29 -0
- package/package.json +1 -1
- package/prisma/cms-schema.prisma +306 -306
- package/prisma/migrations/0001_init/migration.sql +384 -384
- package/prisma/migrations/0002_folders/migration.sql +39 -39
- package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -50
- package/prisma/migrations/0004_script_tags/migration.sql +21 -21
- package/prisma/migrations/0005_password_reset_tokens/migration.sql +20 -20
- package/prisma/migrations/0006_page_builder/migration.sql +38 -38
- package/prisma/migrations/migration_lock.toml +3 -3
- package/prisma/schema.prisma +549 -549
package/dist/api/handlers.js
CHANGED
|
@@ -33,6 +33,7 @@ import { validateMimeType, checkMagicBytes } from '../security/upload.js';
|
|
|
33
33
|
import { sanitizeHtml } from '../security/sanitize.js';
|
|
34
34
|
import { getActuateConfig, getActuateCoreVersion } from '../config/runtime.js';
|
|
35
35
|
import { validateEnvShape } from '../diagnostics/env.js';
|
|
36
|
+
import { generateApiKey, hashApiKey, looksLikeApiKey, validateApiKeyScope, validateApiKeyGlobalScope, validateApiKeyMediaScope, validateApiKeyPageBuilderScope, validateApiKeyIp, } from '../security/api-key-enhanced.js';
|
|
36
37
|
// Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
|
|
37
38
|
// Returns { put, del, ... } from @vercel/blob when available.
|
|
38
39
|
async function importBlobStorage() {
|
|
@@ -176,6 +177,79 @@ function modelNotAvailable(name) {
|
|
|
176
177
|
'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
|
|
177
178
|
'See https://actuatecms.dev/docs/database-setup for required models.', 501);
|
|
178
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* XML-escape a value for safe inclusion in sitemap content. Sitemaps reject
|
|
182
|
+
* unescaped ampersands and angle brackets; URLs frequently contain `&` query
|
|
183
|
+
* separators, so this is non-optional.
|
|
184
|
+
*/
|
|
185
|
+
function escapeXml(value) {
|
|
186
|
+
return value
|
|
187
|
+
.replace(/&/g, '&')
|
|
188
|
+
.replace(/</g, '<')
|
|
189
|
+
.replace(/>/g, '>')
|
|
190
|
+
.replace(/"/g, '"')
|
|
191
|
+
.replace(/'/g, ''');
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Renders a 1200x630 SVG suitable for og:image. We don't pull in Satori/resvg
|
|
195
|
+
* because most integrators don't need PNG — major crawlers (Facebook, Twitter,
|
|
196
|
+
* LinkedIn, Slack, Discord) handle image/svg+xml correctly. Sites that
|
|
197
|
+
* specifically need PNG can override with their own /og endpoint via
|
|
198
|
+
* @vercel/og and point `seo.defaultOgImage` at it.
|
|
199
|
+
*/
|
|
200
|
+
function renderOgSvg(opts) {
|
|
201
|
+
const { title, description, siteName, bg, fg, muted } = opts;
|
|
202
|
+
// Naive line wrapping at ~22 chars (large font). Good enough for an OG card;
|
|
203
|
+
// anything longer than ~3 lines gets truncated with an ellipsis.
|
|
204
|
+
const wrap = (text, maxChars, maxLines) => {
|
|
205
|
+
const words = text.split(/\s+/);
|
|
206
|
+
const lines = [];
|
|
207
|
+
let current = '';
|
|
208
|
+
for (const w of words) {
|
|
209
|
+
if (lines.length >= maxLines)
|
|
210
|
+
break;
|
|
211
|
+
const next = current ? `${current} ${w}` : w;
|
|
212
|
+
if (next.length > maxChars) {
|
|
213
|
+
if (current)
|
|
214
|
+
lines.push(current);
|
|
215
|
+
current = w;
|
|
216
|
+
if (lines.length === maxLines - 1 && words.indexOf(w) < words.length - 1) {
|
|
217
|
+
lines.push(current.length > maxChars ? current.slice(0, maxChars - 1) + '…' : current + '…');
|
|
218
|
+
current = '';
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
current = next;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (current && lines.length < maxLines)
|
|
227
|
+
lines.push(current);
|
|
228
|
+
return lines;
|
|
229
|
+
};
|
|
230
|
+
const escapeSvg = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
231
|
+
const titleLines = wrap(title, 28, 3);
|
|
232
|
+
const descLines = description ? wrap(description, 60, 2) : [];
|
|
233
|
+
const titleEls = titleLines
|
|
234
|
+
.map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 78}">${escapeSvg(line)}</tspan>`)
|
|
235
|
+
.join('');
|
|
236
|
+
const descEls = descLines
|
|
237
|
+
.map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 36}">${escapeSvg(line)}</tspan>`)
|
|
238
|
+
.join('');
|
|
239
|
+
// Layout: site name top-left, title bottom-left, description below title.
|
|
240
|
+
// Coordinates are roughly aligned to the 1200x630 spec used by every major
|
|
241
|
+
// social platform.
|
|
242
|
+
const titleY = descLines.length > 0 ? 360 : 420;
|
|
243
|
+
const descY = titleY + 80 * titleLines.length;
|
|
244
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
245
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
|
246
|
+
<rect width="1200" height="630" fill="${bg}"/>
|
|
247
|
+
${siteName ? `<text x="60" y="100" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="${muted}">${escapeSvg(siteName)}</text>` : ''}
|
|
248
|
+
<text x="60" y="${titleY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="68" font-weight="700" fill="${fg}">${titleEls}</text>
|
|
249
|
+
${descLines.length > 0 ? `<text x="60" y="${descY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="30" fill="${muted}">${descEls}</text>` : ''}
|
|
250
|
+
<rect x="60" y="560" width="60" height="6" fill="${fg}" rx="3"/>
|
|
251
|
+
</svg>`;
|
|
252
|
+
}
|
|
179
253
|
async function safeCount(model, where) {
|
|
180
254
|
try {
|
|
181
255
|
if (!model || typeof model !== 'object')
|
|
@@ -293,6 +367,49 @@ async function extractSession(request) {
|
|
|
293
367
|
}
|
|
294
368
|
if (!token)
|
|
295
369
|
return null;
|
|
370
|
+
// API key path. Keys are recognized by the `act_sk_` prefix and looked up
|
|
371
|
+
// by SHA-256 hash. We never JWT-verify these tokens — they are opaque
|
|
372
|
+
// random secrets stored as hashes in `actuate_api_keys`.
|
|
373
|
+
if (looksLikeApiKey(token)) {
|
|
374
|
+
try {
|
|
375
|
+
const d = getDB();
|
|
376
|
+
if (!hasModel(d, 'apiKey'))
|
|
377
|
+
return null;
|
|
378
|
+
const hash = await hashApiKey(token);
|
|
379
|
+
const apiKey = await d.apiKey.findUnique({ where: { keyHash: hash } });
|
|
380
|
+
if (!apiKey)
|
|
381
|
+
return null;
|
|
382
|
+
if (apiKey.revokedAt)
|
|
383
|
+
return null;
|
|
384
|
+
if (apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() < Date.now())
|
|
385
|
+
return null;
|
|
386
|
+
const ipRestrictions = Array.isArray(apiKey.ipRestrictions)
|
|
387
|
+
? apiKey.ipRestrictions
|
|
388
|
+
: apiKey.ipRestrictions
|
|
389
|
+
? (apiKey.ipRestrictions.allow ?? null)
|
|
390
|
+
: null;
|
|
391
|
+
if (ipRestrictions && ipRestrictions.length > 0) {
|
|
392
|
+
const ip = getClientIp(request);
|
|
393
|
+
if (!validateApiKeyIp(ipRestrictions, ip))
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const scopes = apiKey.scopes ?? {};
|
|
397
|
+
// Fire-and-forget lastUsedAt update; never block the request on it.
|
|
398
|
+
void d.apiKey
|
|
399
|
+
.update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } })
|
|
400
|
+
.catch(() => { });
|
|
401
|
+
return {
|
|
402
|
+
userId: apiKey.userId,
|
|
403
|
+
role: scopes.admin ? 'ADMIN' : 'API_KEY',
|
|
404
|
+
sessionId: apiKey.id,
|
|
405
|
+
apiKey: { id: apiKey.id, scopes },
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Session JWT path.
|
|
296
413
|
try {
|
|
297
414
|
const payload = await verifySession(token, { secret: getSessionSecret() });
|
|
298
415
|
const d = getDB();
|
|
@@ -365,6 +482,57 @@ async function requireAuth(request) {
|
|
|
365
482
|
}
|
|
366
483
|
return { session };
|
|
367
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Check that the request's auth context permits `action` on `collection`.
|
|
487
|
+
* - Session-authenticated requests fall through to `requireRole` semantics:
|
|
488
|
+
* write actions require WRITE_ROLES.
|
|
489
|
+
* - API-key-authenticated requests must have an explicit scope match.
|
|
490
|
+
*/
|
|
491
|
+
function requireCollectionScope(session, collection, action) {
|
|
492
|
+
if (session.apiKey) {
|
|
493
|
+
if (!validateApiKeyScope(session.apiKey.scopes, collection, action)) {
|
|
494
|
+
return errorResponse(`API key does not have permission to ${action} on collection "${collection}"`, 403);
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
if (action === 'read')
|
|
499
|
+
return null;
|
|
500
|
+
return requireRole(session.role, WRITE_ROLES);
|
|
501
|
+
}
|
|
502
|
+
function requireGlobalScope(session, slug) {
|
|
503
|
+
if (session.apiKey) {
|
|
504
|
+
if (!validateApiKeyGlobalScope(session.apiKey.scopes, slug)) {
|
|
505
|
+
return errorResponse(`API key does not have permission for global "${slug}"`, 403);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
function requireMediaScope(session) {
|
|
511
|
+
if (session.apiKey) {
|
|
512
|
+
if (!validateApiKeyMediaScope(session.apiKey.scopes)) {
|
|
513
|
+
return errorResponse('API key does not have media scope', 403);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
function requirePageBuilderScope(session) {
|
|
519
|
+
if (session.apiKey) {
|
|
520
|
+
if (!validateApiKeyPageBuilderScope(session.apiKey.scopes)) {
|
|
521
|
+
return errorResponse('API key does not have pageBuilder scope', 403);
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
return requireRole(session.role, ADMIN_ROLES);
|
|
526
|
+
}
|
|
527
|
+
function requireAdminScope(session) {
|
|
528
|
+
if (session.apiKey) {
|
|
529
|
+
if (!session.apiKey.scopes.admin) {
|
|
530
|
+
return errorResponse('API key does not have admin scope', 403);
|
|
531
|
+
}
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
return requireRole(session.role, ADMIN_ROLES);
|
|
535
|
+
}
|
|
368
536
|
function buildActionContext(session, db, locale) {
|
|
369
537
|
return {
|
|
370
538
|
userId: session.userId,
|
|
@@ -1118,6 +1286,9 @@ export function registerCMSRoutes(router) {
|
|
|
1118
1286
|
const auth = await requireAuth(request);
|
|
1119
1287
|
if (auth.error)
|
|
1120
1288
|
return auth.error;
|
|
1289
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
|
|
1290
|
+
if (scopeErr)
|
|
1291
|
+
return scopeErr;
|
|
1121
1292
|
const url = new URL(request.url);
|
|
1122
1293
|
const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
|
|
1123
1294
|
const result = await listDocuments({
|
|
@@ -1155,6 +1326,9 @@ export function registerCMSRoutes(router) {
|
|
|
1155
1326
|
const auth = await requireAuth(request);
|
|
1156
1327
|
if (auth.error)
|
|
1157
1328
|
return auth.error;
|
|
1329
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
|
|
1330
|
+
if (scopeErr)
|
|
1331
|
+
return scopeErr;
|
|
1158
1332
|
const ctx = buildActionContext(auth.session, db());
|
|
1159
1333
|
const doc = await getDocument(params.slug, params.id, ctx);
|
|
1160
1334
|
if (!doc) {
|
|
@@ -1175,9 +1349,9 @@ export function registerCMSRoutes(router) {
|
|
|
1175
1349
|
const auth = await requireAuth(request);
|
|
1176
1350
|
if (auth.error)
|
|
1177
1351
|
return auth.error;
|
|
1178
|
-
const
|
|
1179
|
-
if (
|
|
1180
|
-
return
|
|
1352
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'create');
|
|
1353
|
+
if (scopeErr)
|
|
1354
|
+
return scopeErr;
|
|
1181
1355
|
const body = (await request.json());
|
|
1182
1356
|
const ctx = buildActionContext(auth.session, db());
|
|
1183
1357
|
const doc = await createDocument(params.slug, body, ctx);
|
|
@@ -1197,9 +1371,9 @@ export function registerCMSRoutes(router) {
|
|
|
1197
1371
|
const auth = await requireAuth(request);
|
|
1198
1372
|
if (auth.error)
|
|
1199
1373
|
return auth.error;
|
|
1200
|
-
const
|
|
1201
|
-
if (
|
|
1202
|
-
return
|
|
1374
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'update');
|
|
1375
|
+
if (scopeErr)
|
|
1376
|
+
return scopeErr;
|
|
1203
1377
|
const body = (await request.json());
|
|
1204
1378
|
const ctx = buildActionContext(auth.session, db());
|
|
1205
1379
|
const doc = await updateDocument(params.slug, params.id, body, ctx);
|
|
@@ -1219,9 +1393,9 @@ export function registerCMSRoutes(router) {
|
|
|
1219
1393
|
const auth = await requireAuth(request);
|
|
1220
1394
|
if (auth.error)
|
|
1221
1395
|
return auth.error;
|
|
1222
|
-
const
|
|
1223
|
-
if (
|
|
1224
|
-
return
|
|
1396
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'delete');
|
|
1397
|
+
if (scopeErr)
|
|
1398
|
+
return scopeErr;
|
|
1225
1399
|
const ctx = buildActionContext(auth.session, db());
|
|
1226
1400
|
await deleteDocument(params.slug, params.id, ctx);
|
|
1227
1401
|
await logEvent({
|
|
@@ -1286,6 +1460,9 @@ export function registerCMSRoutes(router) {
|
|
|
1286
1460
|
const auth = await requireAuth(request);
|
|
1287
1461
|
if (auth.error)
|
|
1288
1462
|
return auth.error;
|
|
1463
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1464
|
+
if (scopeErr)
|
|
1465
|
+
return scopeErr;
|
|
1289
1466
|
const body = (await request.json());
|
|
1290
1467
|
if (!body.filename || !body.contentType) {
|
|
1291
1468
|
return errorResponse('filename and contentType are required', 400);
|
|
@@ -1330,6 +1507,9 @@ export function registerCMSRoutes(router) {
|
|
|
1330
1507
|
const auth = await requireAuth(request);
|
|
1331
1508
|
if (auth.error)
|
|
1332
1509
|
return auth.error;
|
|
1510
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1511
|
+
if (scopeErr)
|
|
1512
|
+
return scopeErr;
|
|
1333
1513
|
// Reject *before* buffering the body. We require a valid content-length
|
|
1334
1514
|
// header so that chunked / no-length requests (which would otherwise
|
|
1335
1515
|
// bypass this gate and allow `request.formData()` to buffer unbounded
|
|
@@ -1661,9 +1841,14 @@ export function registerCMSRoutes(router) {
|
|
|
1661
1841
|
const auth = await requireAuth(request);
|
|
1662
1842
|
if (auth.error)
|
|
1663
1843
|
return auth.error;
|
|
1664
|
-
const
|
|
1665
|
-
if (
|
|
1666
|
-
return
|
|
1844
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1845
|
+
if (scopeErr)
|
|
1846
|
+
return scopeErr;
|
|
1847
|
+
if (!auth.session.apiKey) {
|
|
1848
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1849
|
+
if (roleErr)
|
|
1850
|
+
return roleErr;
|
|
1851
|
+
}
|
|
1667
1852
|
const body = (await request.json());
|
|
1668
1853
|
const updated = await db().media.update({
|
|
1669
1854
|
where: { id: params.id },
|
|
@@ -1686,9 +1871,14 @@ export function registerCMSRoutes(router) {
|
|
|
1686
1871
|
const auth = await requireAuth(request);
|
|
1687
1872
|
if (auth.error)
|
|
1688
1873
|
return auth.error;
|
|
1689
|
-
const
|
|
1690
|
-
if (
|
|
1691
|
-
return
|
|
1874
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1875
|
+
if (scopeErr)
|
|
1876
|
+
return scopeErr;
|
|
1877
|
+
if (!auth.session.apiKey) {
|
|
1878
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1879
|
+
if (roleErr)
|
|
1880
|
+
return roleErr;
|
|
1881
|
+
}
|
|
1692
1882
|
const media = await db().media.findUnique({ where: { id: params.id } });
|
|
1693
1883
|
if (!media) {
|
|
1694
1884
|
return errorResponse('Media not found', 404);
|
|
@@ -2218,6 +2408,163 @@ export function registerCMSRoutes(router) {
|
|
|
2218
2408
|
}
|
|
2219
2409
|
});
|
|
2220
2410
|
// ---------------------------------------------------------------------------
|
|
2411
|
+
// API key management (admin-only). Keys are created here, hashed in the DB,
|
|
2412
|
+
// and shown to the caller exactly once. They authenticate programmatic
|
|
2413
|
+
// clients (AI agents, CI, integrations) against the same REST surface as a
|
|
2414
|
+
// user session, but skip CSRF and use scope-based authorization.
|
|
2415
|
+
// ---------------------------------------------------------------------------
|
|
2416
|
+
router.get('/api-keys', async (request) => {
|
|
2417
|
+
try {
|
|
2418
|
+
const auth = await requireAuth(request);
|
|
2419
|
+
if (auth.error)
|
|
2420
|
+
return auth.error;
|
|
2421
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2422
|
+
if (adminErr)
|
|
2423
|
+
return adminErr;
|
|
2424
|
+
const d = db();
|
|
2425
|
+
if (!hasModel(d, 'apiKey'))
|
|
2426
|
+
return json({ data: [] });
|
|
2427
|
+
const keys = await d.apiKey.findMany({
|
|
2428
|
+
orderBy: { createdAt: 'desc' },
|
|
2429
|
+
select: {
|
|
2430
|
+
id: true,
|
|
2431
|
+
name: true,
|
|
2432
|
+
keyPrefix: true,
|
|
2433
|
+
scopes: true,
|
|
2434
|
+
ipRestrictions: true,
|
|
2435
|
+
expiresAt: true,
|
|
2436
|
+
lastUsedAt: true,
|
|
2437
|
+
revokedAt: true,
|
|
2438
|
+
createdAt: true,
|
|
2439
|
+
user: { select: { id: true, name: true, email: true } },
|
|
2440
|
+
},
|
|
2441
|
+
});
|
|
2442
|
+
return json({ data: keys });
|
|
2443
|
+
}
|
|
2444
|
+
catch (err) {
|
|
2445
|
+
return internalError(err, 'api-keys/list');
|
|
2446
|
+
}
|
|
2447
|
+
});
|
|
2448
|
+
router.post('/api-keys', async (request) => {
|
|
2449
|
+
try {
|
|
2450
|
+
const auth = await requireAuth(request);
|
|
2451
|
+
if (auth.error)
|
|
2452
|
+
return auth.error;
|
|
2453
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2454
|
+
if (adminErr)
|
|
2455
|
+
return adminErr;
|
|
2456
|
+
// Issuing a new credential is a sensitive action — require reauth for
|
|
2457
|
+
// session-authenticated callers. API-key-authenticated requests are
|
|
2458
|
+
// already proving possession of a long-lived credential.
|
|
2459
|
+
if (!auth.session.apiKey) {
|
|
2460
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
2461
|
+
if (reauthErr)
|
|
2462
|
+
return reauthErr;
|
|
2463
|
+
}
|
|
2464
|
+
const body = (await request.json());
|
|
2465
|
+
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
|
2466
|
+
return errorResponse('name is required', 400);
|
|
2467
|
+
}
|
|
2468
|
+
if (body.name.length > 100) {
|
|
2469
|
+
return errorResponse('name must be 100 characters or fewer', 400);
|
|
2470
|
+
}
|
|
2471
|
+
const scopes = body.scopes ?? {};
|
|
2472
|
+
// Sanity check the scope shape so we don't store garbage that fails
|
|
2473
|
+
// every authorization check at runtime.
|
|
2474
|
+
if (!scopes.admin &&
|
|
2475
|
+
!scopes.media &&
|
|
2476
|
+
!scopes.pageBuilder &&
|
|
2477
|
+
(!scopes.collections || scopes.collections.length === 0) &&
|
|
2478
|
+
(!scopes.globals || scopes.globals.length === 0)) {
|
|
2479
|
+
return errorResponse('scopes must grant at least one capability (admin, media, pageBuilder, collections, or globals)', 400);
|
|
2480
|
+
}
|
|
2481
|
+
let expiresAt;
|
|
2482
|
+
if (body.expiresAt) {
|
|
2483
|
+
const parsed = new Date(body.expiresAt);
|
|
2484
|
+
if (isNaN(parsed.getTime()))
|
|
2485
|
+
return errorResponse('Invalid expiresAt date', 400);
|
|
2486
|
+
if (parsed.getTime() < Date.now())
|
|
2487
|
+
return errorResponse('expiresAt must be in the future', 400);
|
|
2488
|
+
expiresAt = parsed;
|
|
2489
|
+
}
|
|
2490
|
+
const { key, keyHash, keyPrefix } = await generateApiKey({
|
|
2491
|
+
prefix: 'act_sk',
|
|
2492
|
+
scopes,
|
|
2493
|
+
expiresAt,
|
|
2494
|
+
});
|
|
2495
|
+
const d = db();
|
|
2496
|
+
const record = await d.apiKey.create({
|
|
2497
|
+
data: {
|
|
2498
|
+
name: body.name.trim(),
|
|
2499
|
+
keyHash,
|
|
2500
|
+
keyPrefix,
|
|
2501
|
+
userId: auth.session.userId,
|
|
2502
|
+
scopes: scopes,
|
|
2503
|
+
ipRestrictions: body.ipRestrictions?.length ? body.ipRestrictions : null,
|
|
2504
|
+
expiresAt: expiresAt ?? null,
|
|
2505
|
+
},
|
|
2506
|
+
select: {
|
|
2507
|
+
id: true,
|
|
2508
|
+
name: true,
|
|
2509
|
+
keyPrefix: true,
|
|
2510
|
+
scopes: true,
|
|
2511
|
+
ipRestrictions: true,
|
|
2512
|
+
expiresAt: true,
|
|
2513
|
+
createdAt: true,
|
|
2514
|
+
},
|
|
2515
|
+
});
|
|
2516
|
+
await logEvent({
|
|
2517
|
+
event: 'api_key_created',
|
|
2518
|
+
userId: auth.session.userId,
|
|
2519
|
+
ipAddress: clientIp(request),
|
|
2520
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
2521
|
+
details: { apiKeyId: record.id, name: record.name, scopes },
|
|
2522
|
+
});
|
|
2523
|
+
// The raw `key` is the only thing the caller will ever see; we never
|
|
2524
|
+
// store it in plaintext. Document this in the response shape.
|
|
2525
|
+
return json({ data: { ...record, key } }, 201);
|
|
2526
|
+
}
|
|
2527
|
+
catch (err) {
|
|
2528
|
+
return internalError(err, 'api-keys/create');
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
2531
|
+
router.delete('/api-keys/:id', async (request, params) => {
|
|
2532
|
+
try {
|
|
2533
|
+
const auth = await requireAuth(request);
|
|
2534
|
+
if (auth.error)
|
|
2535
|
+
return auth.error;
|
|
2536
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2537
|
+
if (adminErr)
|
|
2538
|
+
return adminErr;
|
|
2539
|
+
// Same reauth gate as creation — revocation is irreversible and
|
|
2540
|
+
// affects every dependent client.
|
|
2541
|
+
if (!auth.session.apiKey) {
|
|
2542
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
2543
|
+
if (reauthErr)
|
|
2544
|
+
return reauthErr;
|
|
2545
|
+
}
|
|
2546
|
+
const d = db();
|
|
2547
|
+
const existing = await d.apiKey.findUnique({ where: { id: params.id } });
|
|
2548
|
+
if (!existing)
|
|
2549
|
+
return errorResponse('API key not found', 404);
|
|
2550
|
+
await d.apiKey.update({
|
|
2551
|
+
where: { id: params.id },
|
|
2552
|
+
data: { revokedAt: new Date() },
|
|
2553
|
+
});
|
|
2554
|
+
await logEvent({
|
|
2555
|
+
event: 'api_key_revoked',
|
|
2556
|
+
userId: auth.session.userId,
|
|
2557
|
+
ipAddress: clientIp(request),
|
|
2558
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
2559
|
+
details: { apiKeyId: existing.id, name: existing.name },
|
|
2560
|
+
});
|
|
2561
|
+
return json({ data: { revoked: true } });
|
|
2562
|
+
}
|
|
2563
|
+
catch (err) {
|
|
2564
|
+
return internalError(err, 'api-keys/revoke');
|
|
2565
|
+
}
|
|
2566
|
+
});
|
|
2567
|
+
// ---------------------------------------------------------------------------
|
|
2221
2568
|
// Users route
|
|
2222
2569
|
// ---------------------------------------------------------------------------
|
|
2223
2570
|
router.get('/users', async (request) => {
|
|
@@ -2844,6 +3191,237 @@ export function registerCMSRoutes(router) {
|
|
|
2844
3191
|
return internalError(err, 'llms.txt');
|
|
2845
3192
|
}
|
|
2846
3193
|
});
|
|
3194
|
+
// ---------------------------------------------------------------------------
|
|
3195
|
+
// Public SEO surfaces: sitemap.xml, per-collection sitemaps, robots.txt,
|
|
3196
|
+
// and the dynamic /og.png OG-image endpoint. These are unauthenticated by
|
|
3197
|
+
// design — search engines and social crawlers will fetch them.
|
|
3198
|
+
// ---------------------------------------------------------------------------
|
|
3199
|
+
function siteUrlFromRequest(request) {
|
|
3200
|
+
const cfg = getActuateConfig()?.seo;
|
|
3201
|
+
if (cfg?.siteUrl)
|
|
3202
|
+
return cfg.siteUrl.replace(/\/+$/, '');
|
|
3203
|
+
// Fall back to the request origin so the routes work on preview deploys
|
|
3204
|
+
// before the integrator has configured siteUrl.
|
|
3205
|
+
try {
|
|
3206
|
+
const u = new URL(request.url);
|
|
3207
|
+
return `${u.protocol}//${u.host}`;
|
|
3208
|
+
}
|
|
3209
|
+
catch {
|
|
3210
|
+
return '';
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
function sitemapEligibleCollections() {
|
|
3214
|
+
const cfg = getActuateConfig();
|
|
3215
|
+
if (!cfg)
|
|
3216
|
+
return [];
|
|
3217
|
+
const excluded = new Set(cfg.seo?.sitemap?.excludeCollections ?? []);
|
|
3218
|
+
const out = [];
|
|
3219
|
+
for (const [slug, col] of Object.entries(cfg.collections ?? {})) {
|
|
3220
|
+
if (excluded.has(slug))
|
|
3221
|
+
continue;
|
|
3222
|
+
if (col.seo?.excludeFromSitemap)
|
|
3223
|
+
continue;
|
|
3224
|
+
out.push({ slug, urlPrefix: col.urlPrefix, type: col.type, seo: col.seo });
|
|
3225
|
+
}
|
|
3226
|
+
return out;
|
|
3227
|
+
}
|
|
3228
|
+
router.get('/sitemap.xml', async (request) => {
|
|
3229
|
+
try {
|
|
3230
|
+
const cfg = getActuateConfig();
|
|
3231
|
+
if (cfg?.seo?.sitemap?.disabled)
|
|
3232
|
+
return errorResponse('Sitemap disabled', 404);
|
|
3233
|
+
const base = siteUrlFromRequest(request);
|
|
3234
|
+
const cols = sitemapEligibleCollections();
|
|
3235
|
+
// Sitemap index points at /sitemaps/:slug.xml for each collection.
|
|
3236
|
+
const sitemaps = cols
|
|
3237
|
+
.map((c) => [
|
|
3238
|
+
' <sitemap>',
|
|
3239
|
+
` <loc>${base}/api/cms/sitemaps/${c.slug}.xml</loc>`,
|
|
3240
|
+
` <lastmod>${new Date().toISOString()}</lastmod>`,
|
|
3241
|
+
' </sitemap>',
|
|
3242
|
+
].join('\n'))
|
|
3243
|
+
.join('\n');
|
|
3244
|
+
const xml = [
|
|
3245
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3246
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
3247
|
+
sitemaps,
|
|
3248
|
+
'</sitemapindex>',
|
|
3249
|
+
].join('\n');
|
|
3250
|
+
return new Response(xml, {
|
|
3251
|
+
status: 200,
|
|
3252
|
+
headers: {
|
|
3253
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
3254
|
+
'Cache-Control': 'public, max-age=300, s-maxage=600',
|
|
3255
|
+
},
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
catch (err) {
|
|
3259
|
+
return internalError(err, 'sitemap.xml');
|
|
3260
|
+
}
|
|
3261
|
+
});
|
|
3262
|
+
// The router's path-param syntax (`:name`) greedily matches everything up
|
|
3263
|
+
// to the next `/`, so registering as `/sitemaps/:slug.xml` would capture
|
|
3264
|
+
// `slug.xml` into a single param. We register `/sitemaps/:slug` instead and
|
|
3265
|
+
// require the `.xml` suffix in the handler.
|
|
3266
|
+
router.get('/sitemaps/:slug', async (request, params) => {
|
|
3267
|
+
try {
|
|
3268
|
+
const cfg = getActuateConfig();
|
|
3269
|
+
if (cfg?.seo?.sitemap?.disabled)
|
|
3270
|
+
return errorResponse('Sitemap disabled', 404);
|
|
3271
|
+
const slugParam = params.slug ?? '';
|
|
3272
|
+
if (!/\.xml$/i.test(slugParam))
|
|
3273
|
+
return errorResponse('Sitemap not found', 404);
|
|
3274
|
+
const rawSlug = slugParam.replace(/\.xml$/i, '');
|
|
3275
|
+
const collection = cfg?.collections?.[rawSlug];
|
|
3276
|
+
if (!collection)
|
|
3277
|
+
return errorResponse('Collection not found', 404);
|
|
3278
|
+
if (collection.seo?.excludeFromSitemap)
|
|
3279
|
+
return errorResponse('Collection excluded from sitemap', 404);
|
|
3280
|
+
const base = siteUrlFromRequest(request);
|
|
3281
|
+
const docs = await db().document.findMany({
|
|
3282
|
+
where: { collection: rawSlug, deletedAt: null, status: 'PUBLISHED' },
|
|
3283
|
+
select: { slug: true, updatedAt: true, data: true },
|
|
3284
|
+
orderBy: { updatedAt: 'desc' },
|
|
3285
|
+
take: 5000,
|
|
3286
|
+
});
|
|
3287
|
+
const prefix = (collection.urlPrefix ?? '').replace(/^\/|\/$/g, '');
|
|
3288
|
+
const defaultPriority = collection.seo?.sitemapPriority ??
|
|
3289
|
+
cfg?.seo?.sitemap?.defaultPriority ??
|
|
3290
|
+
(collection.type === 'page' ? 0.8 : 0.6);
|
|
3291
|
+
const changefreq = collection.seo?.sitemapChangeFreq ?? cfg?.seo?.sitemap?.defaultChangeFreq ?? 'weekly';
|
|
3292
|
+
const urls = [];
|
|
3293
|
+
// Archive page first (e.g. /blog) for post-type collections.
|
|
3294
|
+
if (collection.seo?.archivePath && collection.type === 'post') {
|
|
3295
|
+
const archive = collection.seo.archivePath.startsWith('http')
|
|
3296
|
+
? collection.seo.archivePath
|
|
3297
|
+
: `${base}${collection.seo.archivePath}`;
|
|
3298
|
+
urls.push([
|
|
3299
|
+
' <url>',
|
|
3300
|
+
` <loc>${escapeXml(archive)}</loc>`,
|
|
3301
|
+
` <lastmod>${new Date().toISOString()}</lastmod>`,
|
|
3302
|
+
` <changefreq>${changefreq}</changefreq>`,
|
|
3303
|
+
' <priority>0.6</priority>',
|
|
3304
|
+
' </url>',
|
|
3305
|
+
].join('\n'));
|
|
3306
|
+
}
|
|
3307
|
+
for (const d of docs) {
|
|
3308
|
+
const data = d.data || {};
|
|
3309
|
+
const slug = d.slug ?? data.slug;
|
|
3310
|
+
if (!slug)
|
|
3311
|
+
continue;
|
|
3312
|
+
const loc = prefix ? `${base}/${prefix}/${slug}` : `${base}/${slug}`;
|
|
3313
|
+
urls.push([
|
|
3314
|
+
' <url>',
|
|
3315
|
+
` <loc>${escapeXml(loc)}</loc>`,
|
|
3316
|
+
` <lastmod>${d.updatedAt?.toISOString() ?? new Date().toISOString()}</lastmod>`,
|
|
3317
|
+
` <changefreq>${changefreq}</changefreq>`,
|
|
3318
|
+
` <priority>${defaultPriority.toFixed(1)}</priority>`,
|
|
3319
|
+
' </url>',
|
|
3320
|
+
].join('\n'));
|
|
3321
|
+
}
|
|
3322
|
+
const xml = [
|
|
3323
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3324
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
3325
|
+
urls.join('\n'),
|
|
3326
|
+
'</urlset>',
|
|
3327
|
+
].join('\n');
|
|
3328
|
+
return new Response(xml, {
|
|
3329
|
+
status: 200,
|
|
3330
|
+
headers: {
|
|
3331
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
3332
|
+
'Cache-Control': 'public, max-age=300, s-maxage=600',
|
|
3333
|
+
},
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
3336
|
+
catch (err) {
|
|
3337
|
+
return internalError(err, 'sitemaps/:slug.xml');
|
|
3338
|
+
}
|
|
3339
|
+
});
|
|
3340
|
+
router.get('/robots.txt', async (request) => {
|
|
3341
|
+
try {
|
|
3342
|
+
const cfg = getActuateConfig();
|
|
3343
|
+
const seo = cfg?.seo;
|
|
3344
|
+
if (seo?.robots?.disabled)
|
|
3345
|
+
return errorResponse('robots.txt disabled', 404);
|
|
3346
|
+
const base = siteUrlFromRequest(request);
|
|
3347
|
+
const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
|
|
3348
|
+
const lines = [
|
|
3349
|
+
'User-agent: *',
|
|
3350
|
+
'Allow: /',
|
|
3351
|
+
'Disallow: /admin',
|
|
3352
|
+
'Disallow: /api/',
|
|
3353
|
+
'',
|
|
3354
|
+
];
|
|
3355
|
+
for (const rule of seo?.robots?.additionalRules ?? []) {
|
|
3356
|
+
lines.push(`User-agent: ${rule.userAgent}`);
|
|
3357
|
+
for (const allow of rule.allow ?? [])
|
|
3358
|
+
lines.push(`Allow: ${allow}`);
|
|
3359
|
+
for (const dis of rule.disallow ?? [])
|
|
3360
|
+
lines.push(`Disallow: ${dis}`);
|
|
3361
|
+
lines.push('');
|
|
3362
|
+
}
|
|
3363
|
+
if (seo?.robots?.blockAIBots) {
|
|
3364
|
+
const bots = [
|
|
3365
|
+
'GPTBot',
|
|
3366
|
+
'ChatGPT-User',
|
|
3367
|
+
'ClaudeBot',
|
|
3368
|
+
'Claude-Web',
|
|
3369
|
+
'anthropic-ai',
|
|
3370
|
+
'Bytespider',
|
|
3371
|
+
'CCBot',
|
|
3372
|
+
'Google-Extended',
|
|
3373
|
+
];
|
|
3374
|
+
for (const bot of bots) {
|
|
3375
|
+
lines.push(`User-agent: ${bot}`);
|
|
3376
|
+
lines.push('Disallow: /');
|
|
3377
|
+
lines.push('');
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
if (sitemapUrl)
|
|
3381
|
+
lines.push(`Sitemap: ${sitemapUrl}`, '');
|
|
3382
|
+
return new Response(lines.join('\n'), {
|
|
3383
|
+
status: 200,
|
|
3384
|
+
headers: {
|
|
3385
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
3386
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
3387
|
+
},
|
|
3388
|
+
});
|
|
3389
|
+
}
|
|
3390
|
+
catch (err) {
|
|
3391
|
+
return internalError(err, 'robots.txt');
|
|
3392
|
+
}
|
|
3393
|
+
});
|
|
3394
|
+
router.get('/og.png', async (request) => {
|
|
3395
|
+
try {
|
|
3396
|
+
const cfg = getActuateConfig();
|
|
3397
|
+
if (cfg?.seo?.ogImage?.disabled)
|
|
3398
|
+
return errorResponse('og.png disabled', 404);
|
|
3399
|
+
const url = new URL(request.url);
|
|
3400
|
+
const title = url.searchParams.get('title') ?? cfg?.seo?.siteName ?? 'Untitled';
|
|
3401
|
+
const description = url.searchParams.get('description') ?? undefined;
|
|
3402
|
+
const siteName = url.searchParams.get('siteName') ?? cfg?.seo?.siteName;
|
|
3403
|
+
const theme = url.searchParams.get('theme') ?? cfg?.seo?.ogImage?.theme ?? 'light';
|
|
3404
|
+
const bg = theme === 'dark' ? '#0a0a0a' : '#ffffff';
|
|
3405
|
+
const fg = theme === 'dark' ? '#fafafa' : '#0a0a0a';
|
|
3406
|
+
const muted = theme === 'dark' ? '#a1a1aa' : '#71717a';
|
|
3407
|
+
// SVG OG image. Crawlers (Facebook, Twitter/X, LinkedIn, Slack, Discord)
|
|
3408
|
+
// all accept image/svg+xml when served with the right Content-Type. We
|
|
3409
|
+
// chose SVG over PNG to avoid forcing a Satori/resvg dependency on
|
|
3410
|
+
// every integrator; sites that need PNG can override with `og:image`
|
|
3411
|
+
// pointing at their own /og endpoint built with @vercel/og.
|
|
3412
|
+
const svg = renderOgSvg({ title, description, siteName, theme, bg, fg, muted });
|
|
3413
|
+
return new Response(svg, {
|
|
3414
|
+
status: 200,
|
|
3415
|
+
headers: {
|
|
3416
|
+
'Content-Type': 'image/svg+xml; charset=utf-8',
|
|
3417
|
+
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
|
|
3418
|
+
},
|
|
3419
|
+
});
|
|
3420
|
+
}
|
|
3421
|
+
catch (err) {
|
|
3422
|
+
return internalError(err, 'og.png');
|
|
3423
|
+
}
|
|
3424
|
+
});
|
|
2847
3425
|
router.get('/seo/schema/:documentId', async (request, params) => {
|
|
2848
3426
|
try {
|
|
2849
3427
|
const auth = await requireAuth(request);
|
|
@@ -3180,6 +3758,28 @@ export function registerCMSRoutes(router) {
|
|
|
3180
3758
|
const docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
|
|
3181
3759
|
const layout = await resolveLayout(pathParam, docData, matchedCollection);
|
|
3182
3760
|
const { _layout: _omit, ...cleanData } = docData;
|
|
3761
|
+
// Compose page meta + JSON-LD up front so client renderers (Next.js
|
|
3762
|
+
// generateMetadata, plain SSR, MCP agents) don't have to re-derive
|
|
3763
|
+
// schema, OG tags, and canonical URLs from raw doc data. Reads the
|
|
3764
|
+
// collection's SEO config and the site-wide SEO defaults.
|
|
3765
|
+
const cfg = getActuateConfig();
|
|
3766
|
+
const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
|
|
3767
|
+
const { composePageMeta } = await import('../seo/page-meta.js');
|
|
3768
|
+
const composed = composePageMeta({
|
|
3769
|
+
doc: {
|
|
3770
|
+
id: doc.id,
|
|
3771
|
+
collection: doc.collection,
|
|
3772
|
+
slug: doc.slug ?? cleanData.slug ?? null,
|
|
3773
|
+
data: cleanData,
|
|
3774
|
+
publishedAt: doc.publishedAt,
|
|
3775
|
+
updatedAt: doc.updatedAt,
|
|
3776
|
+
structuredData: doc.structuredData ?? null,
|
|
3777
|
+
pageSettings: cleanData.pageSettings ?? null,
|
|
3778
|
+
},
|
|
3779
|
+
collection: collectionDef,
|
|
3780
|
+
config: cfg ?? null,
|
|
3781
|
+
siteUrl: siteUrlFromRequest(request),
|
|
3782
|
+
});
|
|
3183
3783
|
return json({
|
|
3184
3784
|
data: {
|
|
3185
3785
|
id: doc.id,
|
|
@@ -3187,8 +3787,18 @@ export function registerCMSRoutes(router) {
|
|
|
3187
3787
|
data: cleanData,
|
|
3188
3788
|
status: doc.status,
|
|
3189
3789
|
publishedAt: doc.publishedAt,
|
|
3190
|
-
structuredData: doc.structuredData,
|
|
3790
|
+
structuredData: composed.jsonLd ?? doc.structuredData,
|
|
3791
|
+
},
|
|
3792
|
+
meta: {
|
|
3793
|
+
title: composed.title,
|
|
3794
|
+
description: composed.description,
|
|
3795
|
+
canonical: composed.canonical,
|
|
3796
|
+
url: composed.url,
|
|
3797
|
+
tags: composed.meta,
|
|
3798
|
+
html: composed.metaHtml,
|
|
3191
3799
|
},
|
|
3800
|
+
jsonLd: composed.jsonLd,
|
|
3801
|
+
jsonLdHtml: composed.jsonLdHtml,
|
|
3192
3802
|
...(Object.keys(layout).length > 0 ? { layout } : {}),
|
|
3193
3803
|
});
|
|
3194
3804
|
}
|
|
@@ -3667,6 +4277,9 @@ export function registerCMSRoutes(router) {
|
|
|
3667
4277
|
const auth = await requireAuth(request);
|
|
3668
4278
|
if (auth.error)
|
|
3669
4279
|
return auth.error;
|
|
4280
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
4281
|
+
if (scopeErr)
|
|
4282
|
+
return scopeErr;
|
|
3670
4283
|
const ctx = buildActionContext(auth.session, db());
|
|
3671
4284
|
const global = await getGlobal(params.slug, ctx);
|
|
3672
4285
|
if (!global) {
|
|
@@ -3683,9 +4296,14 @@ export function registerCMSRoutes(router) {
|
|
|
3683
4296
|
const auth = await requireAuth(request);
|
|
3684
4297
|
if (auth.error)
|
|
3685
4298
|
return auth.error;
|
|
3686
|
-
const
|
|
3687
|
-
if (
|
|
3688
|
-
return
|
|
4299
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
4300
|
+
if (scopeErr)
|
|
4301
|
+
return scopeErr;
|
|
4302
|
+
if (!auth.session.apiKey) {
|
|
4303
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4304
|
+
if (roleErr)
|
|
4305
|
+
return roleErr;
|
|
4306
|
+
}
|
|
3689
4307
|
const body = (await request.json());
|
|
3690
4308
|
const ctx = buildActionContext(auth.session, db());
|
|
3691
4309
|
const global = await updateGlobal(params.slug, body, ctx);
|
|
@@ -4463,9 +5081,9 @@ export function registerCMSRoutes(router) {
|
|
|
4463
5081
|
const auth = await requireAuth(request);
|
|
4464
5082
|
if (auth.error)
|
|
4465
5083
|
return auth.error;
|
|
4466
|
-
const
|
|
4467
|
-
if (
|
|
4468
|
-
return
|
|
5084
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
5085
|
+
if (scopeErr)
|
|
5086
|
+
return scopeErr;
|
|
4469
5087
|
// Per-user rate limit. AI generation is the single most expensive
|
|
4470
5088
|
// operation in the CMS — without this, a compromised admin account
|
|
4471
5089
|
// can drain a provider key in minutes.
|
|
@@ -4535,9 +5153,9 @@ export function registerCMSRoutes(router) {
|
|
|
4535
5153
|
const auth = await requireAuth(request);
|
|
4536
5154
|
if (auth.error)
|
|
4537
5155
|
return auth.error;
|
|
4538
|
-
const
|
|
4539
|
-
if (
|
|
4540
|
-
return
|
|
5156
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
5157
|
+
if (scopeErr)
|
|
5158
|
+
return scopeErr;
|
|
4541
5159
|
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
|
|
4542
5160
|
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4543
5161
|
}
|
|
@@ -4568,14 +5186,129 @@ export function registerCMSRoutes(router) {
|
|
|
4568
5186
|
return internalError(err, 'page-builder generate-block');
|
|
4569
5187
|
}
|
|
4570
5188
|
});
|
|
5189
|
+
/**
|
|
5190
|
+
* One-shot page creation: run the AI page generator and persist the result
|
|
5191
|
+
* as a new document in a single call. Designed for AI agents that want to
|
|
5192
|
+
* create a complete page from a prompt without orchestrating two requests
|
|
5193
|
+
* (generate, then create). Defaults to status=DRAFT so the human reviewer
|
|
5194
|
+
* can polish before publishing.
|
|
5195
|
+
*/
|
|
5196
|
+
router.post('/page-builder/create', async (request) => {
|
|
5197
|
+
try {
|
|
5198
|
+
const auth = await requireAuth(request);
|
|
5199
|
+
if (auth.error)
|
|
5200
|
+
return auth.error;
|
|
5201
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
5202
|
+
if (scopeErr)
|
|
5203
|
+
return scopeErr;
|
|
5204
|
+
// The create path also writes to a collection, so the API key must hold
|
|
5205
|
+
// create scope on the destination collection (defaults to 'pages').
|
|
5206
|
+
const body = (await request.json());
|
|
5207
|
+
const targetCollection = body.collection ?? 'pages';
|
|
5208
|
+
const collectionScopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
|
|
5209
|
+
if (collectionScopeErr)
|
|
5210
|
+
return collectionScopeErr;
|
|
5211
|
+
if (!body.prompt || typeof body.prompt !== 'string') {
|
|
5212
|
+
return errorResponse('prompt is required', 400);
|
|
5213
|
+
}
|
|
5214
|
+
if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
|
|
5215
|
+
return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
|
|
5216
|
+
}
|
|
5217
|
+
// Same rate-limit bucket as /generate — one create == one expensive LLM
|
|
5218
|
+
// run, so it should count against the same hourly cap.
|
|
5219
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
|
|
5220
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
5221
|
+
}
|
|
5222
|
+
const steps = Array.isArray(body.steps) && body.steps.length > 0
|
|
5223
|
+
? body.steps
|
|
5224
|
+
: ['structure', 'content', 'seo', 'accessibility'];
|
|
5225
|
+
const validSteps = ['structure', 'content', 'seo', 'accessibility'];
|
|
5226
|
+
for (const s of steps) {
|
|
5227
|
+
if (!validSteps.includes(s)) {
|
|
5228
|
+
return errorResponse(`Invalid step: ${s}. Valid steps: ${validSteps.join(', ')}`, 400);
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
let generatePage = null;
|
|
5232
|
+
try {
|
|
5233
|
+
const aiModule = await importAIPlugin();
|
|
5234
|
+
generatePage = aiModule.generatePage;
|
|
5235
|
+
}
|
|
5236
|
+
catch {
|
|
5237
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use page generation.', 501);
|
|
5238
|
+
}
|
|
5239
|
+
const result = await generatePage({
|
|
5240
|
+
prompt: body.prompt,
|
|
5241
|
+
template: body.template,
|
|
5242
|
+
context: body.context,
|
|
5243
|
+
steps,
|
|
5244
|
+
tone: body.tone,
|
|
5245
|
+
});
|
|
5246
|
+
const tree = result?.tree;
|
|
5247
|
+
if (!tree) {
|
|
5248
|
+
return errorResponse('Page generation returned no tree', 502);
|
|
5249
|
+
}
|
|
5250
|
+
// Pull SEO metadata out of the generator output so we can store it on
|
|
5251
|
+
// the document alongside the layout tree.
|
|
5252
|
+
const seoStep = (result?.steps ?? []).find((s) => s.step === 'seo');
|
|
5253
|
+
const meta = seoStep?.data ?? {};
|
|
5254
|
+
const title = body.title ?? meta.title ?? meta.metaTitle ?? 'Untitled';
|
|
5255
|
+
const slug = body.slug ??
|
|
5256
|
+
title
|
|
5257
|
+
.toLowerCase()
|
|
5258
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
5259
|
+
.replace(/^-|-$/g, '');
|
|
5260
|
+
// Determine final status — explicit body.status wins, then publish: true,
|
|
5261
|
+
// then DRAFT.
|
|
5262
|
+
const status = body.status === 'PUBLISHED' || body.publish === true ? 'PUBLISHED' : 'DRAFT';
|
|
5263
|
+
const docPayload = {
|
|
5264
|
+
title,
|
|
5265
|
+
slug,
|
|
5266
|
+
status,
|
|
5267
|
+
layout: tree,
|
|
5268
|
+
pageSettings: {
|
|
5269
|
+
metaTitle: meta.metaTitle ?? meta.title ?? title,
|
|
5270
|
+
metaDescription: meta.metaDescription ?? meta.description ?? '',
|
|
5271
|
+
...(meta.canonical ? { canonical: meta.canonical } : {}),
|
|
5272
|
+
...(meta.schemaType ? { schemaType: meta.schemaType } : {}),
|
|
5273
|
+
},
|
|
5274
|
+
};
|
|
5275
|
+
const ctx = buildActionContext(auth.session, db());
|
|
5276
|
+
const doc = await createDocument(targetCollection, docPayload, ctx);
|
|
5277
|
+
await logEvent({
|
|
5278
|
+
event: 'settings_changed',
|
|
5279
|
+
userId: auth.session.userId,
|
|
5280
|
+
details: {
|
|
5281
|
+
action: 'page_create_from_prompt',
|
|
5282
|
+
collection: targetCollection,
|
|
5283
|
+
documentId: doc?.id,
|
|
5284
|
+
prompt: redactSecrets(body.prompt).slice(0, 500),
|
|
5285
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
5286
|
+
totalDurationMs: result.totalDurationMs,
|
|
5287
|
+
},
|
|
5288
|
+
});
|
|
5289
|
+
return json({
|
|
5290
|
+
data: {
|
|
5291
|
+
document: doc,
|
|
5292
|
+
generation: {
|
|
5293
|
+
steps: result.steps,
|
|
5294
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
5295
|
+
totalDurationMs: result.totalDurationMs,
|
|
5296
|
+
},
|
|
5297
|
+
},
|
|
5298
|
+
}, 201);
|
|
5299
|
+
}
|
|
5300
|
+
catch (err) {
|
|
5301
|
+
return internalError(err, 'page-builder create');
|
|
5302
|
+
}
|
|
5303
|
+
});
|
|
4571
5304
|
router.post('/page-builder/audit-a11y', async (request) => {
|
|
4572
5305
|
try {
|
|
4573
5306
|
const auth = await requireAuth(request);
|
|
4574
5307
|
if (auth.error)
|
|
4575
5308
|
return auth.error;
|
|
4576
|
-
const
|
|
4577
|
-
if (
|
|
4578
|
-
return
|
|
5309
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
5310
|
+
if (scopeErr)
|
|
5311
|
+
return scopeErr;
|
|
4579
5312
|
const body = await request.json();
|
|
4580
5313
|
const tree = body.tree;
|
|
4581
5314
|
if (!tree || tree.type !== 'page') {
|
|
@@ -4593,9 +5326,9 @@ export function registerCMSRoutes(router) {
|
|
|
4593
5326
|
const auth = await requireAuth(request);
|
|
4594
5327
|
if (auth.error)
|
|
4595
5328
|
return auth.error;
|
|
4596
|
-
const
|
|
4597
|
-
if (
|
|
4598
|
-
return
|
|
5329
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
5330
|
+
if (scopeErr)
|
|
5331
|
+
return scopeErr;
|
|
4599
5332
|
const body = await request.json();
|
|
4600
5333
|
const tree = body.tree;
|
|
4601
5334
|
if (!tree || tree.type !== 'page') {
|