@actuate-media/cms-core 0.13.0 → 0.14.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 +217 -0
- package/dist/__tests__/api/api-key-auth.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/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +19 -2
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +427 -30
- package/dist/api/handlers.js.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/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() {
|
|
@@ -293,6 +294,49 @@ async function extractSession(request) {
|
|
|
293
294
|
}
|
|
294
295
|
if (!token)
|
|
295
296
|
return null;
|
|
297
|
+
// API key path. Keys are recognized by the `act_sk_` prefix and looked up
|
|
298
|
+
// by SHA-256 hash. We never JWT-verify these tokens — they are opaque
|
|
299
|
+
// random secrets stored as hashes in `actuate_api_keys`.
|
|
300
|
+
if (looksLikeApiKey(token)) {
|
|
301
|
+
try {
|
|
302
|
+
const d = getDB();
|
|
303
|
+
if (!hasModel(d, 'apiKey'))
|
|
304
|
+
return null;
|
|
305
|
+
const hash = await hashApiKey(token);
|
|
306
|
+
const apiKey = await d.apiKey.findUnique({ where: { keyHash: hash } });
|
|
307
|
+
if (!apiKey)
|
|
308
|
+
return null;
|
|
309
|
+
if (apiKey.revokedAt)
|
|
310
|
+
return null;
|
|
311
|
+
if (apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() < Date.now())
|
|
312
|
+
return null;
|
|
313
|
+
const ipRestrictions = Array.isArray(apiKey.ipRestrictions)
|
|
314
|
+
? apiKey.ipRestrictions
|
|
315
|
+
: apiKey.ipRestrictions
|
|
316
|
+
? apiKey.ipRestrictions.allow ?? null
|
|
317
|
+
: null;
|
|
318
|
+
if (ipRestrictions && ipRestrictions.length > 0) {
|
|
319
|
+
const ip = getClientIp(request);
|
|
320
|
+
if (!validateApiKeyIp(ipRestrictions, ip))
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const scopes = apiKey.scopes ?? {};
|
|
324
|
+
// Fire-and-forget lastUsedAt update; never block the request on it.
|
|
325
|
+
void d.apiKey
|
|
326
|
+
.update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } })
|
|
327
|
+
.catch(() => { });
|
|
328
|
+
return {
|
|
329
|
+
userId: apiKey.userId,
|
|
330
|
+
role: scopes.admin ? 'ADMIN' : 'API_KEY',
|
|
331
|
+
sessionId: apiKey.id,
|
|
332
|
+
apiKey: { id: apiKey.id, scopes },
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Session JWT path.
|
|
296
340
|
try {
|
|
297
341
|
const payload = await verifySession(token, { secret: getSessionSecret() });
|
|
298
342
|
const d = getDB();
|
|
@@ -365,6 +409,57 @@ async function requireAuth(request) {
|
|
|
365
409
|
}
|
|
366
410
|
return { session };
|
|
367
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Check that the request's auth context permits `action` on `collection`.
|
|
414
|
+
* - Session-authenticated requests fall through to `requireRole` semantics:
|
|
415
|
+
* write actions require WRITE_ROLES.
|
|
416
|
+
* - API-key-authenticated requests must have an explicit scope match.
|
|
417
|
+
*/
|
|
418
|
+
function requireCollectionScope(session, collection, action) {
|
|
419
|
+
if (session.apiKey) {
|
|
420
|
+
if (!validateApiKeyScope(session.apiKey.scopes, collection, action)) {
|
|
421
|
+
return errorResponse(`API key does not have permission to ${action} on collection "${collection}"`, 403);
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (action === 'read')
|
|
426
|
+
return null;
|
|
427
|
+
return requireRole(session.role, WRITE_ROLES);
|
|
428
|
+
}
|
|
429
|
+
function requireGlobalScope(session, slug) {
|
|
430
|
+
if (session.apiKey) {
|
|
431
|
+
if (!validateApiKeyGlobalScope(session.apiKey.scopes, slug)) {
|
|
432
|
+
return errorResponse(`API key does not have permission for global "${slug}"`, 403);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
function requireMediaScope(session) {
|
|
438
|
+
if (session.apiKey) {
|
|
439
|
+
if (!validateApiKeyMediaScope(session.apiKey.scopes)) {
|
|
440
|
+
return errorResponse('API key does not have media scope', 403);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
function requirePageBuilderScope(session) {
|
|
446
|
+
if (session.apiKey) {
|
|
447
|
+
if (!validateApiKeyPageBuilderScope(session.apiKey.scopes)) {
|
|
448
|
+
return errorResponse('API key does not have pageBuilder scope', 403);
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
return requireRole(session.role, ADMIN_ROLES);
|
|
453
|
+
}
|
|
454
|
+
function requireAdminScope(session) {
|
|
455
|
+
if (session.apiKey) {
|
|
456
|
+
if (!session.apiKey.scopes.admin) {
|
|
457
|
+
return errorResponse('API key does not have admin scope', 403);
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
return requireRole(session.role, ADMIN_ROLES);
|
|
462
|
+
}
|
|
368
463
|
function buildActionContext(session, db, locale) {
|
|
369
464
|
return {
|
|
370
465
|
userId: session.userId,
|
|
@@ -1118,6 +1213,9 @@ export function registerCMSRoutes(router) {
|
|
|
1118
1213
|
const auth = await requireAuth(request);
|
|
1119
1214
|
if (auth.error)
|
|
1120
1215
|
return auth.error;
|
|
1216
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
|
|
1217
|
+
if (scopeErr)
|
|
1218
|
+
return scopeErr;
|
|
1121
1219
|
const url = new URL(request.url);
|
|
1122
1220
|
const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
|
|
1123
1221
|
const result = await listDocuments({
|
|
@@ -1155,6 +1253,9 @@ export function registerCMSRoutes(router) {
|
|
|
1155
1253
|
const auth = await requireAuth(request);
|
|
1156
1254
|
if (auth.error)
|
|
1157
1255
|
return auth.error;
|
|
1256
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
|
|
1257
|
+
if (scopeErr)
|
|
1258
|
+
return scopeErr;
|
|
1158
1259
|
const ctx = buildActionContext(auth.session, db());
|
|
1159
1260
|
const doc = await getDocument(params.slug, params.id, ctx);
|
|
1160
1261
|
if (!doc) {
|
|
@@ -1175,9 +1276,9 @@ export function registerCMSRoutes(router) {
|
|
|
1175
1276
|
const auth = await requireAuth(request);
|
|
1176
1277
|
if (auth.error)
|
|
1177
1278
|
return auth.error;
|
|
1178
|
-
const
|
|
1179
|
-
if (
|
|
1180
|
-
return
|
|
1279
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'create');
|
|
1280
|
+
if (scopeErr)
|
|
1281
|
+
return scopeErr;
|
|
1181
1282
|
const body = (await request.json());
|
|
1182
1283
|
const ctx = buildActionContext(auth.session, db());
|
|
1183
1284
|
const doc = await createDocument(params.slug, body, ctx);
|
|
@@ -1197,9 +1298,9 @@ export function registerCMSRoutes(router) {
|
|
|
1197
1298
|
const auth = await requireAuth(request);
|
|
1198
1299
|
if (auth.error)
|
|
1199
1300
|
return auth.error;
|
|
1200
|
-
const
|
|
1201
|
-
if (
|
|
1202
|
-
return
|
|
1301
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'update');
|
|
1302
|
+
if (scopeErr)
|
|
1303
|
+
return scopeErr;
|
|
1203
1304
|
const body = (await request.json());
|
|
1204
1305
|
const ctx = buildActionContext(auth.session, db());
|
|
1205
1306
|
const doc = await updateDocument(params.slug, params.id, body, ctx);
|
|
@@ -1219,9 +1320,9 @@ export function registerCMSRoutes(router) {
|
|
|
1219
1320
|
const auth = await requireAuth(request);
|
|
1220
1321
|
if (auth.error)
|
|
1221
1322
|
return auth.error;
|
|
1222
|
-
const
|
|
1223
|
-
if (
|
|
1224
|
-
return
|
|
1323
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'delete');
|
|
1324
|
+
if (scopeErr)
|
|
1325
|
+
return scopeErr;
|
|
1225
1326
|
const ctx = buildActionContext(auth.session, db());
|
|
1226
1327
|
await deleteDocument(params.slug, params.id, ctx);
|
|
1227
1328
|
await logEvent({
|
|
@@ -1286,6 +1387,9 @@ export function registerCMSRoutes(router) {
|
|
|
1286
1387
|
const auth = await requireAuth(request);
|
|
1287
1388
|
if (auth.error)
|
|
1288
1389
|
return auth.error;
|
|
1390
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1391
|
+
if (scopeErr)
|
|
1392
|
+
return scopeErr;
|
|
1289
1393
|
const body = (await request.json());
|
|
1290
1394
|
if (!body.filename || !body.contentType) {
|
|
1291
1395
|
return errorResponse('filename and contentType are required', 400);
|
|
@@ -1330,6 +1434,9 @@ export function registerCMSRoutes(router) {
|
|
|
1330
1434
|
const auth = await requireAuth(request);
|
|
1331
1435
|
if (auth.error)
|
|
1332
1436
|
return auth.error;
|
|
1437
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1438
|
+
if (scopeErr)
|
|
1439
|
+
return scopeErr;
|
|
1333
1440
|
// Reject *before* buffering the body. We require a valid content-length
|
|
1334
1441
|
// header so that chunked / no-length requests (which would otherwise
|
|
1335
1442
|
// bypass this gate and allow `request.formData()` to buffer unbounded
|
|
@@ -1661,9 +1768,14 @@ export function registerCMSRoutes(router) {
|
|
|
1661
1768
|
const auth = await requireAuth(request);
|
|
1662
1769
|
if (auth.error)
|
|
1663
1770
|
return auth.error;
|
|
1664
|
-
const
|
|
1665
|
-
if (
|
|
1666
|
-
return
|
|
1771
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1772
|
+
if (scopeErr)
|
|
1773
|
+
return scopeErr;
|
|
1774
|
+
if (!auth.session.apiKey) {
|
|
1775
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1776
|
+
if (roleErr)
|
|
1777
|
+
return roleErr;
|
|
1778
|
+
}
|
|
1667
1779
|
const body = (await request.json());
|
|
1668
1780
|
const updated = await db().media.update({
|
|
1669
1781
|
where: { id: params.id },
|
|
@@ -1686,9 +1798,14 @@ export function registerCMSRoutes(router) {
|
|
|
1686
1798
|
const auth = await requireAuth(request);
|
|
1687
1799
|
if (auth.error)
|
|
1688
1800
|
return auth.error;
|
|
1689
|
-
const
|
|
1690
|
-
if (
|
|
1691
|
-
return
|
|
1801
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1802
|
+
if (scopeErr)
|
|
1803
|
+
return scopeErr;
|
|
1804
|
+
if (!auth.session.apiKey) {
|
|
1805
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1806
|
+
if (roleErr)
|
|
1807
|
+
return roleErr;
|
|
1808
|
+
}
|
|
1692
1809
|
const media = await db().media.findUnique({ where: { id: params.id } });
|
|
1693
1810
|
if (!media) {
|
|
1694
1811
|
return errorResponse('Media not found', 404);
|
|
@@ -2218,6 +2335,163 @@ export function registerCMSRoutes(router) {
|
|
|
2218
2335
|
}
|
|
2219
2336
|
});
|
|
2220
2337
|
// ---------------------------------------------------------------------------
|
|
2338
|
+
// API key management (admin-only). Keys are created here, hashed in the DB,
|
|
2339
|
+
// and shown to the caller exactly once. They authenticate programmatic
|
|
2340
|
+
// clients (AI agents, CI, integrations) against the same REST surface as a
|
|
2341
|
+
// user session, but skip CSRF and use scope-based authorization.
|
|
2342
|
+
// ---------------------------------------------------------------------------
|
|
2343
|
+
router.get('/api-keys', async (request) => {
|
|
2344
|
+
try {
|
|
2345
|
+
const auth = await requireAuth(request);
|
|
2346
|
+
if (auth.error)
|
|
2347
|
+
return auth.error;
|
|
2348
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2349
|
+
if (adminErr)
|
|
2350
|
+
return adminErr;
|
|
2351
|
+
const d = db();
|
|
2352
|
+
if (!hasModel(d, 'apiKey'))
|
|
2353
|
+
return json({ data: [] });
|
|
2354
|
+
const keys = await d.apiKey.findMany({
|
|
2355
|
+
orderBy: { createdAt: 'desc' },
|
|
2356
|
+
select: {
|
|
2357
|
+
id: true,
|
|
2358
|
+
name: true,
|
|
2359
|
+
keyPrefix: true,
|
|
2360
|
+
scopes: true,
|
|
2361
|
+
ipRestrictions: true,
|
|
2362
|
+
expiresAt: true,
|
|
2363
|
+
lastUsedAt: true,
|
|
2364
|
+
revokedAt: true,
|
|
2365
|
+
createdAt: true,
|
|
2366
|
+
user: { select: { id: true, name: true, email: true } },
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
return json({ data: keys });
|
|
2370
|
+
}
|
|
2371
|
+
catch (err) {
|
|
2372
|
+
return internalError(err, 'api-keys/list');
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
router.post('/api-keys', async (request) => {
|
|
2376
|
+
try {
|
|
2377
|
+
const auth = await requireAuth(request);
|
|
2378
|
+
if (auth.error)
|
|
2379
|
+
return auth.error;
|
|
2380
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2381
|
+
if (adminErr)
|
|
2382
|
+
return adminErr;
|
|
2383
|
+
// Issuing a new credential is a sensitive action — require reauth for
|
|
2384
|
+
// session-authenticated callers. API-key-authenticated requests are
|
|
2385
|
+
// already proving possession of a long-lived credential.
|
|
2386
|
+
if (!auth.session.apiKey) {
|
|
2387
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
2388
|
+
if (reauthErr)
|
|
2389
|
+
return reauthErr;
|
|
2390
|
+
}
|
|
2391
|
+
const body = (await request.json());
|
|
2392
|
+
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
|
2393
|
+
return errorResponse('name is required', 400);
|
|
2394
|
+
}
|
|
2395
|
+
if (body.name.length > 100) {
|
|
2396
|
+
return errorResponse('name must be 100 characters or fewer', 400);
|
|
2397
|
+
}
|
|
2398
|
+
const scopes = body.scopes ?? {};
|
|
2399
|
+
// Sanity check the scope shape so we don't store garbage that fails
|
|
2400
|
+
// every authorization check at runtime.
|
|
2401
|
+
if (!scopes.admin &&
|
|
2402
|
+
!scopes.media &&
|
|
2403
|
+
!scopes.pageBuilder &&
|
|
2404
|
+
(!scopes.collections || scopes.collections.length === 0) &&
|
|
2405
|
+
(!scopes.globals || scopes.globals.length === 0)) {
|
|
2406
|
+
return errorResponse('scopes must grant at least one capability (admin, media, pageBuilder, collections, or globals)', 400);
|
|
2407
|
+
}
|
|
2408
|
+
let expiresAt;
|
|
2409
|
+
if (body.expiresAt) {
|
|
2410
|
+
const parsed = new Date(body.expiresAt);
|
|
2411
|
+
if (isNaN(parsed.getTime()))
|
|
2412
|
+
return errorResponse('Invalid expiresAt date', 400);
|
|
2413
|
+
if (parsed.getTime() < Date.now())
|
|
2414
|
+
return errorResponse('expiresAt must be in the future', 400);
|
|
2415
|
+
expiresAt = parsed;
|
|
2416
|
+
}
|
|
2417
|
+
const { key, keyHash, keyPrefix } = await generateApiKey({
|
|
2418
|
+
prefix: 'act_sk',
|
|
2419
|
+
scopes,
|
|
2420
|
+
expiresAt,
|
|
2421
|
+
});
|
|
2422
|
+
const d = db();
|
|
2423
|
+
const record = await d.apiKey.create({
|
|
2424
|
+
data: {
|
|
2425
|
+
name: body.name.trim(),
|
|
2426
|
+
keyHash,
|
|
2427
|
+
keyPrefix,
|
|
2428
|
+
userId: auth.session.userId,
|
|
2429
|
+
scopes: scopes,
|
|
2430
|
+
ipRestrictions: body.ipRestrictions?.length ? body.ipRestrictions : null,
|
|
2431
|
+
expiresAt: expiresAt ?? null,
|
|
2432
|
+
},
|
|
2433
|
+
select: {
|
|
2434
|
+
id: true,
|
|
2435
|
+
name: true,
|
|
2436
|
+
keyPrefix: true,
|
|
2437
|
+
scopes: true,
|
|
2438
|
+
ipRestrictions: true,
|
|
2439
|
+
expiresAt: true,
|
|
2440
|
+
createdAt: true,
|
|
2441
|
+
},
|
|
2442
|
+
});
|
|
2443
|
+
await logEvent({
|
|
2444
|
+
event: 'api_key_created',
|
|
2445
|
+
userId: auth.session.userId,
|
|
2446
|
+
ipAddress: clientIp(request),
|
|
2447
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
2448
|
+
details: { apiKeyId: record.id, name: record.name, scopes },
|
|
2449
|
+
});
|
|
2450
|
+
// The raw `key` is the only thing the caller will ever see; we never
|
|
2451
|
+
// store it in plaintext. Document this in the response shape.
|
|
2452
|
+
return json({ data: { ...record, key } }, 201);
|
|
2453
|
+
}
|
|
2454
|
+
catch (err) {
|
|
2455
|
+
return internalError(err, 'api-keys/create');
|
|
2456
|
+
}
|
|
2457
|
+
});
|
|
2458
|
+
router.delete('/api-keys/:id', async (request, params) => {
|
|
2459
|
+
try {
|
|
2460
|
+
const auth = await requireAuth(request);
|
|
2461
|
+
if (auth.error)
|
|
2462
|
+
return auth.error;
|
|
2463
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2464
|
+
if (adminErr)
|
|
2465
|
+
return adminErr;
|
|
2466
|
+
// Same reauth gate as creation — revocation is irreversible and
|
|
2467
|
+
// affects every dependent client.
|
|
2468
|
+
if (!auth.session.apiKey) {
|
|
2469
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
2470
|
+
if (reauthErr)
|
|
2471
|
+
return reauthErr;
|
|
2472
|
+
}
|
|
2473
|
+
const d = db();
|
|
2474
|
+
const existing = await d.apiKey.findUnique({ where: { id: params.id } });
|
|
2475
|
+
if (!existing)
|
|
2476
|
+
return errorResponse('API key not found', 404);
|
|
2477
|
+
await d.apiKey.update({
|
|
2478
|
+
where: { id: params.id },
|
|
2479
|
+
data: { revokedAt: new Date() },
|
|
2480
|
+
});
|
|
2481
|
+
await logEvent({
|
|
2482
|
+
event: 'api_key_revoked',
|
|
2483
|
+
userId: auth.session.userId,
|
|
2484
|
+
ipAddress: clientIp(request),
|
|
2485
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
2486
|
+
details: { apiKeyId: existing.id, name: existing.name },
|
|
2487
|
+
});
|
|
2488
|
+
return json({ data: { revoked: true } });
|
|
2489
|
+
}
|
|
2490
|
+
catch (err) {
|
|
2491
|
+
return internalError(err, 'api-keys/revoke');
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
// ---------------------------------------------------------------------------
|
|
2221
2495
|
// Users route
|
|
2222
2496
|
// ---------------------------------------------------------------------------
|
|
2223
2497
|
router.get('/users', async (request) => {
|
|
@@ -3667,6 +3941,9 @@ export function registerCMSRoutes(router) {
|
|
|
3667
3941
|
const auth = await requireAuth(request);
|
|
3668
3942
|
if (auth.error)
|
|
3669
3943
|
return auth.error;
|
|
3944
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
3945
|
+
if (scopeErr)
|
|
3946
|
+
return scopeErr;
|
|
3670
3947
|
const ctx = buildActionContext(auth.session, db());
|
|
3671
3948
|
const global = await getGlobal(params.slug, ctx);
|
|
3672
3949
|
if (!global) {
|
|
@@ -3683,9 +3960,14 @@ export function registerCMSRoutes(router) {
|
|
|
3683
3960
|
const auth = await requireAuth(request);
|
|
3684
3961
|
if (auth.error)
|
|
3685
3962
|
return auth.error;
|
|
3686
|
-
const
|
|
3687
|
-
if (
|
|
3688
|
-
return
|
|
3963
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
3964
|
+
if (scopeErr)
|
|
3965
|
+
return scopeErr;
|
|
3966
|
+
if (!auth.session.apiKey) {
|
|
3967
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3968
|
+
if (roleErr)
|
|
3969
|
+
return roleErr;
|
|
3970
|
+
}
|
|
3689
3971
|
const body = (await request.json());
|
|
3690
3972
|
const ctx = buildActionContext(auth.session, db());
|
|
3691
3973
|
const global = await updateGlobal(params.slug, body, ctx);
|
|
@@ -4463,9 +4745,9 @@ export function registerCMSRoutes(router) {
|
|
|
4463
4745
|
const auth = await requireAuth(request);
|
|
4464
4746
|
if (auth.error)
|
|
4465
4747
|
return auth.error;
|
|
4466
|
-
const
|
|
4467
|
-
if (
|
|
4468
|
-
return
|
|
4748
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4749
|
+
if (scopeErr)
|
|
4750
|
+
return scopeErr;
|
|
4469
4751
|
// Per-user rate limit. AI generation is the single most expensive
|
|
4470
4752
|
// operation in the CMS — without this, a compromised admin account
|
|
4471
4753
|
// can drain a provider key in minutes.
|
|
@@ -4535,9 +4817,9 @@ export function registerCMSRoutes(router) {
|
|
|
4535
4817
|
const auth = await requireAuth(request);
|
|
4536
4818
|
if (auth.error)
|
|
4537
4819
|
return auth.error;
|
|
4538
|
-
const
|
|
4539
|
-
if (
|
|
4540
|
-
return
|
|
4820
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4821
|
+
if (scopeErr)
|
|
4822
|
+
return scopeErr;
|
|
4541
4823
|
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
|
|
4542
4824
|
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4543
4825
|
}
|
|
@@ -4568,14 +4850,129 @@ export function registerCMSRoutes(router) {
|
|
|
4568
4850
|
return internalError(err, 'page-builder generate-block');
|
|
4569
4851
|
}
|
|
4570
4852
|
});
|
|
4853
|
+
/**
|
|
4854
|
+
* One-shot page creation: run the AI page generator and persist the result
|
|
4855
|
+
* as a new document in a single call. Designed for AI agents that want to
|
|
4856
|
+
* create a complete page from a prompt without orchestrating two requests
|
|
4857
|
+
* (generate, then create). Defaults to status=DRAFT so the human reviewer
|
|
4858
|
+
* can polish before publishing.
|
|
4859
|
+
*/
|
|
4860
|
+
router.post('/page-builder/create', async (request) => {
|
|
4861
|
+
try {
|
|
4862
|
+
const auth = await requireAuth(request);
|
|
4863
|
+
if (auth.error)
|
|
4864
|
+
return auth.error;
|
|
4865
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4866
|
+
if (scopeErr)
|
|
4867
|
+
return scopeErr;
|
|
4868
|
+
// The create path also writes to a collection, so the API key must hold
|
|
4869
|
+
// create scope on the destination collection (defaults to 'pages').
|
|
4870
|
+
const body = (await request.json());
|
|
4871
|
+
const targetCollection = body.collection ?? 'pages';
|
|
4872
|
+
const collectionScopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
|
|
4873
|
+
if (collectionScopeErr)
|
|
4874
|
+
return collectionScopeErr;
|
|
4875
|
+
if (!body.prompt || typeof body.prompt !== 'string') {
|
|
4876
|
+
return errorResponse('prompt is required', 400);
|
|
4877
|
+
}
|
|
4878
|
+
if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
|
|
4879
|
+
return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
|
|
4880
|
+
}
|
|
4881
|
+
// Same rate-limit bucket as /generate — one create == one expensive LLM
|
|
4882
|
+
// run, so it should count against the same hourly cap.
|
|
4883
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
|
|
4884
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4885
|
+
}
|
|
4886
|
+
const steps = Array.isArray(body.steps) && body.steps.length > 0
|
|
4887
|
+
? body.steps
|
|
4888
|
+
: ['structure', 'content', 'seo', 'accessibility'];
|
|
4889
|
+
const validSteps = ['structure', 'content', 'seo', 'accessibility'];
|
|
4890
|
+
for (const s of steps) {
|
|
4891
|
+
if (!validSteps.includes(s)) {
|
|
4892
|
+
return errorResponse(`Invalid step: ${s}. Valid steps: ${validSteps.join(', ')}`, 400);
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
4895
|
+
let generatePage = null;
|
|
4896
|
+
try {
|
|
4897
|
+
const aiModule = await importAIPlugin();
|
|
4898
|
+
generatePage = aiModule.generatePage;
|
|
4899
|
+
}
|
|
4900
|
+
catch {
|
|
4901
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use page generation.', 501);
|
|
4902
|
+
}
|
|
4903
|
+
const result = await generatePage({
|
|
4904
|
+
prompt: body.prompt,
|
|
4905
|
+
template: body.template,
|
|
4906
|
+
context: body.context,
|
|
4907
|
+
steps,
|
|
4908
|
+
tone: body.tone,
|
|
4909
|
+
});
|
|
4910
|
+
const tree = result?.tree;
|
|
4911
|
+
if (!tree) {
|
|
4912
|
+
return errorResponse('Page generation returned no tree', 502);
|
|
4913
|
+
}
|
|
4914
|
+
// Pull SEO metadata out of the generator output so we can store it on
|
|
4915
|
+
// the document alongside the layout tree.
|
|
4916
|
+
const seoStep = (result?.steps ?? []).find((s) => s.step === 'seo');
|
|
4917
|
+
const meta = seoStep?.data ?? {};
|
|
4918
|
+
const title = body.title ?? meta.title ?? meta.metaTitle ?? 'Untitled';
|
|
4919
|
+
const slug = body.slug ??
|
|
4920
|
+
title
|
|
4921
|
+
.toLowerCase()
|
|
4922
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
4923
|
+
.replace(/^-|-$/g, '');
|
|
4924
|
+
// Determine final status — explicit body.status wins, then publish: true,
|
|
4925
|
+
// then DRAFT.
|
|
4926
|
+
const status = body.status === 'PUBLISHED' || body.publish === true ? 'PUBLISHED' : 'DRAFT';
|
|
4927
|
+
const docPayload = {
|
|
4928
|
+
title,
|
|
4929
|
+
slug,
|
|
4930
|
+
status,
|
|
4931
|
+
layout: tree,
|
|
4932
|
+
pageSettings: {
|
|
4933
|
+
metaTitle: meta.metaTitle ?? meta.title ?? title,
|
|
4934
|
+
metaDescription: meta.metaDescription ?? meta.description ?? '',
|
|
4935
|
+
...(meta.canonical ? { canonical: meta.canonical } : {}),
|
|
4936
|
+
...(meta.schemaType ? { schemaType: meta.schemaType } : {}),
|
|
4937
|
+
},
|
|
4938
|
+
};
|
|
4939
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4940
|
+
const doc = await createDocument(targetCollection, docPayload, ctx);
|
|
4941
|
+
await logEvent({
|
|
4942
|
+
event: 'settings_changed',
|
|
4943
|
+
userId: auth.session.userId,
|
|
4944
|
+
details: {
|
|
4945
|
+
action: 'page_create_from_prompt',
|
|
4946
|
+
collection: targetCollection,
|
|
4947
|
+
documentId: doc?.id,
|
|
4948
|
+
prompt: redactSecrets(body.prompt).slice(0, 500),
|
|
4949
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
4950
|
+
totalDurationMs: result.totalDurationMs,
|
|
4951
|
+
},
|
|
4952
|
+
});
|
|
4953
|
+
return json({
|
|
4954
|
+
data: {
|
|
4955
|
+
document: doc,
|
|
4956
|
+
generation: {
|
|
4957
|
+
steps: result.steps,
|
|
4958
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
4959
|
+
totalDurationMs: result.totalDurationMs,
|
|
4960
|
+
},
|
|
4961
|
+
},
|
|
4962
|
+
}, 201);
|
|
4963
|
+
}
|
|
4964
|
+
catch (err) {
|
|
4965
|
+
return internalError(err, 'page-builder create');
|
|
4966
|
+
}
|
|
4967
|
+
});
|
|
4571
4968
|
router.post('/page-builder/audit-a11y', async (request) => {
|
|
4572
4969
|
try {
|
|
4573
4970
|
const auth = await requireAuth(request);
|
|
4574
4971
|
if (auth.error)
|
|
4575
4972
|
return auth.error;
|
|
4576
|
-
const
|
|
4577
|
-
if (
|
|
4578
|
-
return
|
|
4973
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4974
|
+
if (scopeErr)
|
|
4975
|
+
return scopeErr;
|
|
4579
4976
|
const body = await request.json();
|
|
4580
4977
|
const tree = body.tree;
|
|
4581
4978
|
if (!tree || tree.type !== 'page') {
|
|
@@ -4593,9 +4990,9 @@ export function registerCMSRoutes(router) {
|
|
|
4593
4990
|
const auth = await requireAuth(request);
|
|
4594
4991
|
if (auth.error)
|
|
4595
4992
|
return auth.error;
|
|
4596
|
-
const
|
|
4597
|
-
if (
|
|
4598
|
-
return
|
|
4993
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4994
|
+
if (scopeErr)
|
|
4995
|
+
return scopeErr;
|
|
4599
4996
|
const body = await request.json();
|
|
4600
4997
|
const tree = body.tree;
|
|
4601
4998
|
if (!tree || tree.type !== 'page') {
|