@actuate-media/cms-core 0.19.0 → 0.20.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/dist/__tests__/api/ai-quality.test.d.ts +2 -0
- package/dist/__tests__/api/ai-quality.test.d.ts.map +1 -0
- package/dist/__tests__/api/ai-quality.test.js +470 -0
- package/dist/__tests__/api/ai-quality.test.js.map +1 -0
- package/dist/__tests__/seo/config-store.test.d.ts +2 -0
- package/dist/__tests__/seo/config-store.test.d.ts.map +1 -0
- package/dist/__tests__/seo/config-store.test.js +167 -0
- package/dist/__tests__/seo/config-store.test.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +4 -0
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +566 -21
- package/dist/api/handlers.js.map +1 -1
- package/dist/seo/config-store.d.ts +61 -0
- package/dist/seo/config-store.d.ts.map +1 -0
- package/dist/seo/config-store.js +158 -0
- package/dist/seo/config-store.js.map +1 -0
- 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/package.json +1 -1
package/dist/api/handlers.js
CHANGED
|
@@ -864,6 +864,132 @@ export function registerCMSRoutes(router) {
|
|
|
864
864
|
return internalError(err, 'login');
|
|
865
865
|
}
|
|
866
866
|
});
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
// Dev-only auth bypass — `GET /api/cms/dev/login`
|
|
869
|
+
//
|
|
870
|
+
// Triple-gated escape hatch for local development:
|
|
871
|
+
// 1. `ACTUATE_DEV_AUTH_BYPASS=1` must be set in the env, AND
|
|
872
|
+
// 2. `NODE_ENV` must NOT be `production`, AND
|
|
873
|
+
// 3. The request must originate from a loopback address (`127.*`, `::1`,
|
|
874
|
+
// or `localhost`).
|
|
875
|
+
//
|
|
876
|
+
// If any gate fails the route returns 404 — indistinguishable from a route
|
|
877
|
+
// that doesn't exist, so probing prod can't even confirm the feature is
|
|
878
|
+
// compiled in.
|
|
879
|
+
//
|
|
880
|
+
// When all three pass we look up or create a synthetic admin user
|
|
881
|
+
// (`dev-bypass@actuate.local`), mint a real session JWT (so every other
|
|
882
|
+
// route does normal session verification), set the `actuate_session`
|
|
883
|
+
// cookie, and 302 → `/securelogin`. The result is a fully-real session;
|
|
884
|
+
// only the credential check was skipped.
|
|
885
|
+
// ---------------------------------------------------------------------------
|
|
886
|
+
const handleDevLogin = async (request) => {
|
|
887
|
+
if (process.env.ACTUATE_DEV_AUTH_BYPASS !== '1')
|
|
888
|
+
return errorResponse('Not found', 404);
|
|
889
|
+
if (process.env.NODE_ENV === 'production')
|
|
890
|
+
return errorResponse('Not found', 404);
|
|
891
|
+
const ip = getClientIp(request);
|
|
892
|
+
const ipIsLoopback = ip === '127.0.0.1' ||
|
|
893
|
+
ip === '::1' ||
|
|
894
|
+
ip === '::ffff:127.0.0.1' ||
|
|
895
|
+
ip === 'localhost' ||
|
|
896
|
+
ip.startsWith('127.');
|
|
897
|
+
// `getClientIp` returns `'unknown'` whenever no trusted proxy header is
|
|
898
|
+
// present, which is the default for `next dev` — there's no proxy in
|
|
899
|
+
// front of the dev server. In that case fall back to the request URL's
|
|
900
|
+
// hostname, which Next.js sets from the actual TCP connection. We only
|
|
901
|
+
// accept the literal loopback hostnames; anything else is rejected.
|
|
902
|
+
let isLoopback = ipIsLoopback;
|
|
903
|
+
if (!isLoopback && (ip === 'unknown' || !ip)) {
|
|
904
|
+
try {
|
|
905
|
+
const host = new URL(request.url).hostname;
|
|
906
|
+
isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
isLoopback = false;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (!isLoopback)
|
|
913
|
+
return errorResponse('Not found', 404);
|
|
914
|
+
try {
|
|
915
|
+
const d = getDB();
|
|
916
|
+
if (!hasModel(d, 'user')) {
|
|
917
|
+
return errorResponse('Dev bypass requires the User model — run prisma migrate first.', 500);
|
|
918
|
+
}
|
|
919
|
+
// Use the dedicated dev-bypass user if it already exists; otherwise
|
|
920
|
+
// mint one. The email is deliberately namespaced under `actuate.local`
|
|
921
|
+
// so it can never collide with a real user (which uses a real domain).
|
|
922
|
+
const DEV_BYPASS_EMAIL = 'dev-bypass@actuate.local';
|
|
923
|
+
let user = await d.user.findUnique({ where: { email: DEV_BYPASS_EMAIL } });
|
|
924
|
+
if (!user) {
|
|
925
|
+
try {
|
|
926
|
+
user = await d.user.create({
|
|
927
|
+
data: {
|
|
928
|
+
email: DEV_BYPASS_EMAIL,
|
|
929
|
+
name: 'Dev Bypass',
|
|
930
|
+
role: 'ADMIN',
|
|
931
|
+
// Bcrypt-shaped placeholder. No real password is ever set;
|
|
932
|
+
// the user cannot log in through the normal flow.
|
|
933
|
+
passwordHash: '$2b$10$dev.bypass.user.cannot.login.through.normal.flow',
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
console.error('[actuate][dev-bypass] user create failed:', err);
|
|
939
|
+
throw err;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const tempSessionId = crypto.randomUUID();
|
|
943
|
+
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
944
|
+
if (hasModel(d, 'session')) {
|
|
945
|
+
await d.session.create({
|
|
946
|
+
data: {
|
|
947
|
+
id: tempSessionId,
|
|
948
|
+
userId: user.id,
|
|
949
|
+
token,
|
|
950
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
951
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
952
|
+
userAgent: request.headers.get('user-agent') ?? null,
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
const csrfToken = await generateCsrfToken();
|
|
957
|
+
const sessionCookie = [
|
|
958
|
+
`actuate_session=${token}`,
|
|
959
|
+
'Path=/',
|
|
960
|
+
'HttpOnly',
|
|
961
|
+
'SameSite=Lax',
|
|
962
|
+
'Max-Age=604800',
|
|
963
|
+
].join('; ');
|
|
964
|
+
const csrfCookie = [
|
|
965
|
+
`actuate_csrf=${csrfToken}`,
|
|
966
|
+
'Path=/',
|
|
967
|
+
'SameSite=Lax',
|
|
968
|
+
'Max-Age=86400',
|
|
969
|
+
].join('; ');
|
|
970
|
+
// Redirect back to the admin so the cookie attaches on the next
|
|
971
|
+
// navigation. `?next=` lets callers steer to a specific deep link.
|
|
972
|
+
const url = new URL(request.url);
|
|
973
|
+
const nextPath = url.searchParams.get('next') ?? '/securelogin';
|
|
974
|
+
const safeNext = nextPath.startsWith('/') ? nextPath : '/securelogin';
|
|
975
|
+
console.warn(`[actuate][dev-bypass] Issued admin session for ${user.email} (${user.id}) — DO NOT USE IN PRODUCTION.`);
|
|
976
|
+
const response = new Response(null, {
|
|
977
|
+
status: 302,
|
|
978
|
+
headers: {
|
|
979
|
+
Location: safeNext,
|
|
980
|
+
...SECURITY_HEADERS,
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
response.headers.append('Set-Cookie', sessionCookie);
|
|
984
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
985
|
+
return response;
|
|
986
|
+
}
|
|
987
|
+
catch (err) {
|
|
988
|
+
return internalError(err, 'dev login');
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
router.get('/dev/login', handleDevLogin);
|
|
992
|
+
router.post('/dev/login', handleDevLogin);
|
|
867
993
|
router.post('/auth/logout', async (request) => {
|
|
868
994
|
try {
|
|
869
995
|
const auth = await requireAuth(request);
|
|
@@ -2262,6 +2388,8 @@ export function registerCMSRoutes(router) {
|
|
|
2262
2388
|
totalUsers: 0,
|
|
2263
2389
|
formCount: 0,
|
|
2264
2390
|
avgSeoScore: 0,
|
|
2391
|
+
webhookCount: 0,
|
|
2392
|
+
webhookActiveCount: 0,
|
|
2265
2393
|
collectionCounts: {},
|
|
2266
2394
|
statusCounts: {},
|
|
2267
2395
|
recentDocuments: [],
|
|
@@ -2311,7 +2439,7 @@ export function registerCMSRoutes(router) {
|
|
|
2311
2439
|
catch {
|
|
2312
2440
|
return json({ data: EMPTY_STATS });
|
|
2313
2441
|
}
|
|
2314
|
-
const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoDocsResult,] = await Promise.allSettled([
|
|
2442
|
+
const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoDocsResult, webhookTotalResult, webhookActiveResult,] = await Promise.allSettled([
|
|
2315
2443
|
safeCount(d.document, { deletedAt: null }),
|
|
2316
2444
|
safeCount(d.media),
|
|
2317
2445
|
safeCount(d.user),
|
|
@@ -2337,6 +2465,11 @@ export function registerCMSRoutes(router) {
|
|
|
2337
2465
|
select: { data: true },
|
|
2338
2466
|
take: 200,
|
|
2339
2467
|
}),
|
|
2468
|
+
// Cheap counts so the dashboard's "Content Delivery" tile doesn't need
|
|
2469
|
+
// a second round-trip. `webhookEndpoint` may not exist on every host,
|
|
2470
|
+
// so `safeCount` swallows missing-model errors and returns 0.
|
|
2471
|
+
safeCount(d.webhookEndpoint),
|
|
2472
|
+
safeCount(d.webhookEndpoint, { active: true }),
|
|
2340
2473
|
]);
|
|
2341
2474
|
const collectionCounts = {};
|
|
2342
2475
|
if (collGroupResult.status === 'fulfilled') {
|
|
@@ -2371,6 +2504,8 @@ export function registerCMSRoutes(router) {
|
|
|
2371
2504
|
totalUsers: userResult.status === 'fulfilled' ? userResult.value : 0,
|
|
2372
2505
|
formCount: formResult.status === 'fulfilled' ? formResult.value : 0,
|
|
2373
2506
|
avgSeoScore,
|
|
2507
|
+
webhookCount: webhookTotalResult.status === 'fulfilled' ? webhookTotalResult.value : 0,
|
|
2508
|
+
webhookActiveCount: webhookActiveResult.status === 'fulfilled' ? webhookActiveResult.value : 0,
|
|
2374
2509
|
collectionCounts,
|
|
2375
2510
|
statusCounts,
|
|
2376
2511
|
recentDocuments: recentDocs,
|
|
@@ -3238,10 +3373,31 @@ export function registerCMSRoutes(router) {
|
|
|
3238
3373
|
// and the dynamic /og.png OG-image endpoint. These are unauthenticated by
|
|
3239
3374
|
// design — search engines and social crawlers will fetch them.
|
|
3240
3375
|
// ---------------------------------------------------------------------------
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3376
|
+
/**
|
|
3377
|
+
* Load the runtime config with DB-stored SEO overrides applied. Used by every
|
|
3378
|
+
* public SEO surface (sitemap, robots, og.png) and by /resolve so admin edits
|
|
3379
|
+
* in the Settings → SEO panel take effect without a redeploy.
|
|
3380
|
+
*
|
|
3381
|
+
* Falls back to the static config when no override row exists yet — first
|
|
3382
|
+
* boot of a freshly-installed CMS keeps working.
|
|
3383
|
+
*/
|
|
3384
|
+
async function loadEffectiveConfig() {
|
|
3385
|
+
const base = getActuateConfig();
|
|
3386
|
+
if (!base)
|
|
3387
|
+
return null;
|
|
3388
|
+
try {
|
|
3389
|
+
const { getSeoOverrides, applySeoOverrides } = await import('../seo/config-store.js');
|
|
3390
|
+
const overrides = await getSeoOverrides(db());
|
|
3391
|
+
return applySeoOverrides(base, overrides);
|
|
3392
|
+
}
|
|
3393
|
+
catch {
|
|
3394
|
+
return base;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
function siteUrlFromRequest(request, cfg) {
|
|
3398
|
+
const seo = (cfg ?? getActuateConfig())?.seo;
|
|
3399
|
+
if (seo?.siteUrl)
|
|
3400
|
+
return seo.siteUrl.replace(/\/+$/, '');
|
|
3245
3401
|
// Fall back to the request origin so the routes work on preview deploys
|
|
3246
3402
|
// before the integrator has configured siteUrl.
|
|
3247
3403
|
try {
|
|
@@ -3252,13 +3408,13 @@ export function registerCMSRoutes(router) {
|
|
|
3252
3408
|
return '';
|
|
3253
3409
|
}
|
|
3254
3410
|
}
|
|
3255
|
-
function sitemapEligibleCollections() {
|
|
3256
|
-
const
|
|
3257
|
-
if (!
|
|
3411
|
+
function sitemapEligibleCollections(cfg) {
|
|
3412
|
+
const resolved = cfg ?? getActuateConfig();
|
|
3413
|
+
if (!resolved)
|
|
3258
3414
|
return [];
|
|
3259
|
-
const excluded = new Set(
|
|
3415
|
+
const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
|
|
3260
3416
|
const out = [];
|
|
3261
|
-
for (const [slug, col] of Object.entries(
|
|
3417
|
+
for (const [slug, col] of Object.entries(resolved.collections ?? {})) {
|
|
3262
3418
|
if (excluded.has(slug))
|
|
3263
3419
|
continue;
|
|
3264
3420
|
if (col.seo?.excludeFromSitemap)
|
|
@@ -3269,11 +3425,11 @@ export function registerCMSRoutes(router) {
|
|
|
3269
3425
|
}
|
|
3270
3426
|
router.get('/sitemap.xml', async (request) => {
|
|
3271
3427
|
try {
|
|
3272
|
-
const cfg =
|
|
3428
|
+
const cfg = await loadEffectiveConfig();
|
|
3273
3429
|
if (cfg?.seo?.sitemap?.disabled)
|
|
3274
3430
|
return errorResponse('Sitemap disabled', 404);
|
|
3275
|
-
const base = siteUrlFromRequest(request);
|
|
3276
|
-
const cols = sitemapEligibleCollections();
|
|
3431
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
3432
|
+
const cols = sitemapEligibleCollections(cfg);
|
|
3277
3433
|
// Sitemap index points at /sitemaps/:slug.xml for each collection.
|
|
3278
3434
|
const sitemaps = cols
|
|
3279
3435
|
.map((c) => [
|
|
@@ -3307,7 +3463,7 @@ export function registerCMSRoutes(router) {
|
|
|
3307
3463
|
// require the `.xml` suffix in the handler.
|
|
3308
3464
|
router.get('/sitemaps/:slug', async (request, params) => {
|
|
3309
3465
|
try {
|
|
3310
|
-
const cfg =
|
|
3466
|
+
const cfg = await loadEffectiveConfig();
|
|
3311
3467
|
if (cfg?.seo?.sitemap?.disabled)
|
|
3312
3468
|
return errorResponse('Sitemap disabled', 404);
|
|
3313
3469
|
const slugParam = params.slug ?? '';
|
|
@@ -3319,7 +3475,7 @@ export function registerCMSRoutes(router) {
|
|
|
3319
3475
|
return errorResponse('Collection not found', 404);
|
|
3320
3476
|
if (collection.seo?.excludeFromSitemap)
|
|
3321
3477
|
return errorResponse('Collection excluded from sitemap', 404);
|
|
3322
|
-
const base = siteUrlFromRequest(request);
|
|
3478
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
3323
3479
|
const docs = await db().document.findMany({
|
|
3324
3480
|
where: { collection: rawSlug, deletedAt: null, status: 'PUBLISHED' },
|
|
3325
3481
|
select: { slug: true, updatedAt: true, data: true },
|
|
@@ -3381,11 +3537,11 @@ export function registerCMSRoutes(router) {
|
|
|
3381
3537
|
});
|
|
3382
3538
|
router.get('/robots.txt', async (request) => {
|
|
3383
3539
|
try {
|
|
3384
|
-
const cfg =
|
|
3540
|
+
const cfg = await loadEffectiveConfig();
|
|
3385
3541
|
const seo = cfg?.seo;
|
|
3386
3542
|
if (seo?.robots?.disabled)
|
|
3387
3543
|
return errorResponse('robots.txt disabled', 404);
|
|
3388
|
-
const base = siteUrlFromRequest(request);
|
|
3544
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
3389
3545
|
const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
|
|
3390
3546
|
const lines = [
|
|
3391
3547
|
'User-agent: *',
|
|
@@ -3435,7 +3591,7 @@ export function registerCMSRoutes(router) {
|
|
|
3435
3591
|
});
|
|
3436
3592
|
router.get('/og.png', async (request) => {
|
|
3437
3593
|
try {
|
|
3438
|
-
const cfg =
|
|
3594
|
+
const cfg = await loadEffectiveConfig();
|
|
3439
3595
|
if (cfg?.seo?.ogImage?.disabled)
|
|
3440
3596
|
return errorResponse('og.png disabled', 404);
|
|
3441
3597
|
const url = new URL(request.url);
|
|
@@ -3639,6 +3795,109 @@ export function registerCMSRoutes(router) {
|
|
|
3639
3795
|
}
|
|
3640
3796
|
});
|
|
3641
3797
|
// ---------------------------------------------------------------------------
|
|
3798
|
+
// SEO config (admin-editable overrides for `actuate.config.ts` seo defaults)
|
|
3799
|
+
// ---------------------------------------------------------------------------
|
|
3800
|
+
/**
|
|
3801
|
+
* Returns:
|
|
3802
|
+
* - `static` → the SEO config from `actuate.config.ts` (read-only, code-level)
|
|
3803
|
+
* - `overrides` → the user-edited DB overrides (or null on first boot)
|
|
3804
|
+
* - `effective` → static + overrides merged (what the runtime actually uses)
|
|
3805
|
+
* - `collections` → list of collection slugs + labels the UI should iterate
|
|
3806
|
+
*
|
|
3807
|
+
* The UI uses `static` to show grey "default" placeholders, `overrides` to
|
|
3808
|
+
* populate the form inputs, and writes back just the changed fields via PUT.
|
|
3809
|
+
*/
|
|
3810
|
+
router.get('/seo/config', async (request) => {
|
|
3811
|
+
try {
|
|
3812
|
+
const auth = await requireAuth(request);
|
|
3813
|
+
if (auth.error)
|
|
3814
|
+
return auth.error;
|
|
3815
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3816
|
+
if (roleErr)
|
|
3817
|
+
return roleErr;
|
|
3818
|
+
const { getSeoOverrides, applySeoOverrides } = await import('../seo/config-store.js');
|
|
3819
|
+
const staticCfg = getActuateConfig();
|
|
3820
|
+
const overrides = await getSeoOverrides(db());
|
|
3821
|
+
const effective = applySeoOverrides(staticCfg, overrides);
|
|
3822
|
+
const collections = Object.entries(staticCfg?.collections ?? {})
|
|
3823
|
+
.map(([slug, col]) => ({
|
|
3824
|
+
slug,
|
|
3825
|
+
label: col.labels?.plural ?? slug,
|
|
3826
|
+
type: col.type ?? 'page',
|
|
3827
|
+
urlPrefix: col.urlPrefix,
|
|
3828
|
+
staticSeo: col.seo ?? null,
|
|
3829
|
+
effectiveSeo: effective?.collections?.[slug]?.seo ?? null,
|
|
3830
|
+
}))
|
|
3831
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
3832
|
+
return json({
|
|
3833
|
+
data: {
|
|
3834
|
+
static: { site: staticCfg?.seo ?? null },
|
|
3835
|
+
overrides: overrides ?? null,
|
|
3836
|
+
effective: { site: effective?.seo ?? null },
|
|
3837
|
+
collections,
|
|
3838
|
+
},
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
catch (err) {
|
|
3842
|
+
return internalError(err, 'seo/config GET');
|
|
3843
|
+
}
|
|
3844
|
+
});
|
|
3845
|
+
router.put('/seo/config', async (request) => {
|
|
3846
|
+
try {
|
|
3847
|
+
const auth = await requireAuth(request);
|
|
3848
|
+
if (auth.error)
|
|
3849
|
+
return auth.error;
|
|
3850
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3851
|
+
if (roleErr)
|
|
3852
|
+
return roleErr;
|
|
3853
|
+
const body = (await request.json().catch(() => null));
|
|
3854
|
+
if (!body || typeof body !== 'object') {
|
|
3855
|
+
return errorResponse('Request body must be an object', 400);
|
|
3856
|
+
}
|
|
3857
|
+
// Shallow shape validation — we don't want to mirror the entire type
|
|
3858
|
+
// here (the config-store merger ignores unknown keys anyway), but we do
|
|
3859
|
+
// want to reject obviously malformed payloads early so the UI gets a
|
|
3860
|
+
// clear 400 instead of a silent no-op.
|
|
3861
|
+
const site = body.site && typeof body.site === 'object' ? body.site : undefined;
|
|
3862
|
+
const collections = body.collections && typeof body.collections === 'object' ? body.collections : undefined;
|
|
3863
|
+
if (site === undefined && collections === undefined) {
|
|
3864
|
+
return errorResponse('Body must include at least one of `site` or `collections`', 400);
|
|
3865
|
+
}
|
|
3866
|
+
const { putSeoOverrides, getSeoOverrides } = await import('../seo/config-store.js');
|
|
3867
|
+
// Merge with whatever's currently stored so the UI can PATCH a single
|
|
3868
|
+
// section without wiping the other — `PUT /seo/config { site: {...} }`
|
|
3869
|
+
// shouldn't blow away per-collection settings.
|
|
3870
|
+
const current = (await getSeoOverrides(db())) ?? {};
|
|
3871
|
+
const next = {
|
|
3872
|
+
site: site !== undefined ? site : current.site,
|
|
3873
|
+
collections: collections !== undefined
|
|
3874
|
+
? { ...(current.collections ?? {}), ...collections }
|
|
3875
|
+
: current.collections,
|
|
3876
|
+
};
|
|
3877
|
+
const saved = await putSeoOverrides(db(), next, auth.session.userId);
|
|
3878
|
+
// Audit so changes to public SEO surface are traceable.
|
|
3879
|
+
try {
|
|
3880
|
+
await db().auditLog?.create?.({
|
|
3881
|
+
data: {
|
|
3882
|
+
action: 'update_seo_config',
|
|
3883
|
+
actorId: auth.session.userId,
|
|
3884
|
+
metadata: {
|
|
3885
|
+
siteKeys: Object.keys(site ?? {}),
|
|
3886
|
+
collectionKeys: Object.keys(collections ?? {}),
|
|
3887
|
+
},
|
|
3888
|
+
},
|
|
3889
|
+
});
|
|
3890
|
+
}
|
|
3891
|
+
catch {
|
|
3892
|
+
// Audit failures must never block the write.
|
|
3893
|
+
}
|
|
3894
|
+
return json({ data: saved });
|
|
3895
|
+
}
|
|
3896
|
+
catch (err) {
|
|
3897
|
+
return internalError(err, 'seo/config PUT');
|
|
3898
|
+
}
|
|
3899
|
+
});
|
|
3900
|
+
// ---------------------------------------------------------------------------
|
|
3642
3901
|
// URL Resolution — maps a public URL path to its document
|
|
3643
3902
|
// ---------------------------------------------------------------------------
|
|
3644
3903
|
const MAX_RESOLVE_DEPTH = 10;
|
|
@@ -3862,8 +4121,9 @@ export function registerCMSRoutes(router) {
|
|
|
3862
4121
|
// Compose page meta + JSON-LD up front so client renderers (Next.js
|
|
3863
4122
|
// generateMetadata, plain SSR, MCP agents) don't have to re-derive
|
|
3864
4123
|
// schema, OG tags, and canonical URLs from raw doc data. Reads the
|
|
3865
|
-
// collection's SEO config and the site-wide SEO defaults
|
|
3866
|
-
|
|
4124
|
+
// collection's SEO config and the site-wide SEO defaults — with
|
|
4125
|
+
// DB overrides from /seo/config applied on top of the static config.
|
|
4126
|
+
const cfg = await loadEffectiveConfig();
|
|
3867
4127
|
const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
|
|
3868
4128
|
const { composePageMeta } = await import('../seo/page-meta.js');
|
|
3869
4129
|
const composed = composePageMeta({
|
|
@@ -3879,7 +4139,7 @@ export function registerCMSRoutes(router) {
|
|
|
3879
4139
|
},
|
|
3880
4140
|
collection: collectionDef,
|
|
3881
4141
|
config: cfg ?? null,
|
|
3882
|
-
siteUrl: siteUrlFromRequest(request),
|
|
4142
|
+
siteUrl: siteUrlFromRequest(request, cfg),
|
|
3883
4143
|
});
|
|
3884
4144
|
return json({
|
|
3885
4145
|
data: {
|
|
@@ -5792,5 +6052,290 @@ export function registerCMSRoutes(router) {
|
|
|
5792
6052
|
return internalError(err, 'page-builder fix-a11y');
|
|
5793
6053
|
}
|
|
5794
6054
|
});
|
|
6055
|
+
/**
|
|
6056
|
+
* Composite document audit — Slice F.
|
|
6057
|
+
*
|
|
6058
|
+
* Runs every scoring dimension plugin-ai exposes (SEO, readability,
|
|
6059
|
+
* accessibility, freshness) plus structural sanity checks (missing
|
|
6060
|
+
* title, missing meta description, alt-less images, thin body) and
|
|
6061
|
+
* returns a single composite grade with per-dimension breakdowns.
|
|
6062
|
+
*
|
|
6063
|
+
* Two input modes:
|
|
6064
|
+
* - `{ collection, id }` — load the document from the DB and audit
|
|
6065
|
+
* its persisted content. Honors field-level access control.
|
|
6066
|
+
* - `{ title, body, ... }` — audit ad-hoc content without persisting,
|
|
6067
|
+
* used by inline AI co-author and live-preview drafts.
|
|
6068
|
+
*
|
|
6069
|
+
* Deterministic: makes no LLM calls, so it is cheap and safe to call
|
|
6070
|
+
* on every save / debounced field edit. The expensive AI features
|
|
6071
|
+
* (suggestions, rewrites) live in separate endpoints.
|
|
6072
|
+
*/
|
|
6073
|
+
router.post('/ai/audit-document', async (request) => {
|
|
6074
|
+
try {
|
|
6075
|
+
const auth = await requireAuth(request);
|
|
6076
|
+
if (auth.error)
|
|
6077
|
+
return auth.error;
|
|
6078
|
+
const body = (await request.json());
|
|
6079
|
+
let auditModule;
|
|
6080
|
+
try {
|
|
6081
|
+
auditModule = await importAIPlugin();
|
|
6082
|
+
}
|
|
6083
|
+
catch {
|
|
6084
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use document audit.', 501);
|
|
6085
|
+
}
|
|
6086
|
+
if (typeof auditModule?.auditDocument !== 'function') {
|
|
6087
|
+
return errorResponse('AI plugin missing `auditDocument`. Upgrade @actuate-media/plugin-ai to >= 0.3.0.', 501);
|
|
6088
|
+
}
|
|
6089
|
+
// Branch A — load from DB.
|
|
6090
|
+
if (body.collection && body.id) {
|
|
6091
|
+
const scopeErr = requireCollectionScope(auth.session, body.collection, 'read');
|
|
6092
|
+
if (scopeErr)
|
|
6093
|
+
return scopeErr;
|
|
6094
|
+
const ctx = buildActionContext(auth.session, db());
|
|
6095
|
+
const doc = (await getDocument(body.collection, body.id, ctx));
|
|
6096
|
+
if (!doc)
|
|
6097
|
+
return errorResponse('Document not found', 404);
|
|
6098
|
+
const data = (doc.data ?? {});
|
|
6099
|
+
const pageSettings = (doc.pageSettings ?? {});
|
|
6100
|
+
const titleStr = String(body.title ?? doc.title ?? data.title ?? data.name ?? '');
|
|
6101
|
+
const bodyStr = String(body.body ?? data.body ?? data.content ?? data.excerpt ?? data.description ?? '');
|
|
6102
|
+
const metaTitle = String(body.metaTitle ?? pageSettings.metaTitle ?? data.metaTitle ?? titleStr);
|
|
6103
|
+
const metaDescription = String(body.metaDescription ??
|
|
6104
|
+
pageSettings.metaDescription ??
|
|
6105
|
+
data.metaDescription ??
|
|
6106
|
+
data.excerpt ??
|
|
6107
|
+
'');
|
|
6108
|
+
const targetKeyword = body.targetKeyword
|
|
6109
|
+
? body.targetKeyword
|
|
6110
|
+
: typeof pageSettings.targetKeyword === 'string'
|
|
6111
|
+
? pageSettings.targetKeyword
|
|
6112
|
+
: typeof data.targetKeyword === 'string'
|
|
6113
|
+
? data.targetKeyword
|
|
6114
|
+
: undefined;
|
|
6115
|
+
const result = auditModule.auditDocument({
|
|
6116
|
+
title: titleStr,
|
|
6117
|
+
body: bodyStr,
|
|
6118
|
+
metaTitle,
|
|
6119
|
+
metaDescription,
|
|
6120
|
+
...(targetKeyword ? { targetKeyword } : {}),
|
|
6121
|
+
...(body.url ? { url: body.url } : {}),
|
|
6122
|
+
...(body.headings ? { headings: body.headings } : {}),
|
|
6123
|
+
...(body.images ? { images: body.images } : {}),
|
|
6124
|
+
...(body.internalLinks ? { internalLinks: body.internalLinks } : {}),
|
|
6125
|
+
...(body.externalLinks ? { externalLinks: body.externalLinks } : {}),
|
|
6126
|
+
...(doc.publishedAt ? { publishedAt: doc.publishedAt } : {}),
|
|
6127
|
+
...(doc.updatedAt ? { updatedAt: doc.updatedAt } : {}),
|
|
6128
|
+
...(body.contentType ? { contentType: body.contentType } : {}),
|
|
6129
|
+
});
|
|
6130
|
+
return json({ data: result });
|
|
6131
|
+
}
|
|
6132
|
+
// Branch B — ad-hoc audit.
|
|
6133
|
+
if (!body.title && !body.body) {
|
|
6134
|
+
return errorResponse('Provide either { collection, id } to audit a stored document or at least { title } / { body } for an ad-hoc audit.', 400);
|
|
6135
|
+
}
|
|
6136
|
+
const result = auditModule.auditDocument({
|
|
6137
|
+
title: body.title ?? '',
|
|
6138
|
+
body: body.body ?? '',
|
|
6139
|
+
...(body.metaTitle ? { metaTitle: body.metaTitle } : {}),
|
|
6140
|
+
...(body.metaDescription ? { metaDescription: body.metaDescription } : {}),
|
|
6141
|
+
...(body.targetKeyword ? { targetKeyword: body.targetKeyword } : {}),
|
|
6142
|
+
...(body.url ? { url: body.url } : {}),
|
|
6143
|
+
...(body.headings ? { headings: body.headings } : {}),
|
|
6144
|
+
...(body.images ? { images: body.images } : {}),
|
|
6145
|
+
...(body.internalLinks ? { internalLinks: body.internalLinks } : {}),
|
|
6146
|
+
...(body.externalLinks ? { externalLinks: body.externalLinks } : {}),
|
|
6147
|
+
...(body.publishedAt ? { publishedAt: body.publishedAt } : {}),
|
|
6148
|
+
...(body.updatedAt ? { updatedAt: body.updatedAt } : {}),
|
|
6149
|
+
...(body.contentType ? { contentType: body.contentType } : {}),
|
|
6150
|
+
});
|
|
6151
|
+
return json({ data: result });
|
|
6152
|
+
}
|
|
6153
|
+
catch (err) {
|
|
6154
|
+
return internalError(err, 'ai audit-document');
|
|
6155
|
+
}
|
|
6156
|
+
});
|
|
6157
|
+
/**
|
|
6158
|
+
* Internal link suggestions — Slice F.
|
|
6159
|
+
*
|
|
6160
|
+
* Given a body of content, scan published CMS documents for phrases the
|
|
6161
|
+
* author could link to (by title or declared keywords) and return a
|
|
6162
|
+
* ranked list of suggestions. The matching is deterministic (no LLM call)
|
|
6163
|
+
* but expensive enough that we cap the candidate set and the response
|
|
6164
|
+
* size to keep p99 under control.
|
|
6165
|
+
*
|
|
6166
|
+
* Optionally constrain candidates to a single collection (e.g. only
|
|
6167
|
+
* suggest links to `services` while editing a blog post).
|
|
6168
|
+
*/
|
|
6169
|
+
/**
|
|
6170
|
+
* Inline AI co-author — text-transformation endpoints (Phase 2).
|
|
6171
|
+
*
|
|
6172
|
+
* Four micro-actions that operate on a chunk of text supplied by the
|
|
6173
|
+
* caller: rewrite, expand, compress, proofread. They wrap the existing
|
|
6174
|
+
* plugin-ai writing primitives so the admin and MCP clients have a
|
|
6175
|
+
* stable HTTP surface.
|
|
6176
|
+
*
|
|
6177
|
+
* Every action is rate-limited under the same bucket as content
|
|
6178
|
+
* generation (20 calls / hour / user) because each one hits the
|
|
6179
|
+
* configured LLM provider.
|
|
6180
|
+
*/
|
|
6181
|
+
router.post('/ai/coauthor', async (request) => {
|
|
6182
|
+
try {
|
|
6183
|
+
const auth = await requireAuth(request);
|
|
6184
|
+
if (auth.error)
|
|
6185
|
+
return auth.error;
|
|
6186
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-coauthor:${auth.session.userId}`))) {
|
|
6187
|
+
return errorResponse('AI co-author rate limit reached. Try again in an hour.', 429);
|
|
6188
|
+
}
|
|
6189
|
+
const body = (await request.json());
|
|
6190
|
+
if (!body.text || typeof body.text !== 'string') {
|
|
6191
|
+
return errorResponse('text is required', 400);
|
|
6192
|
+
}
|
|
6193
|
+
if (body.text.length > AI_CONTEXT_MAX_CHARS) {
|
|
6194
|
+
return errorResponse(`text exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
|
|
6195
|
+
}
|
|
6196
|
+
const action = body.action ?? 'rewrite';
|
|
6197
|
+
if (!['rewrite', 'expand', 'compress', 'proofread'].includes(action)) {
|
|
6198
|
+
return errorResponse(`Unknown action "${action}"`, 400);
|
|
6199
|
+
}
|
|
6200
|
+
let aiModule;
|
|
6201
|
+
try {
|
|
6202
|
+
aiModule = await importAIPlugin();
|
|
6203
|
+
}
|
|
6204
|
+
catch {
|
|
6205
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use the co-author.', 501);
|
|
6206
|
+
}
|
|
6207
|
+
let result;
|
|
6208
|
+
try {
|
|
6209
|
+
if (action === 'rewrite') {
|
|
6210
|
+
if (typeof aiModule.rewrite !== 'function') {
|
|
6211
|
+
return errorResponse('AI plugin missing `rewrite`.', 501);
|
|
6212
|
+
}
|
|
6213
|
+
const text = (await aiModule.rewrite(body.text, body.style, body.tone));
|
|
6214
|
+
result = { text };
|
|
6215
|
+
}
|
|
6216
|
+
else if (action === 'expand') {
|
|
6217
|
+
if (typeof aiModule.expand !== 'function') {
|
|
6218
|
+
return errorResponse('AI plugin missing `expand`.', 501);
|
|
6219
|
+
}
|
|
6220
|
+
const text = (await aiModule.expand(body.text, body.instructions));
|
|
6221
|
+
result = { text };
|
|
6222
|
+
}
|
|
6223
|
+
else if (action === 'compress') {
|
|
6224
|
+
if (typeof aiModule.compress !== 'function') {
|
|
6225
|
+
return errorResponse('AI plugin missing `compress`.', 501);
|
|
6226
|
+
}
|
|
6227
|
+
const text = (await aiModule.compress(body.text, body.targetLength));
|
|
6228
|
+
result = { text };
|
|
6229
|
+
}
|
|
6230
|
+
else {
|
|
6231
|
+
if (typeof aiModule.proofread !== 'function') {
|
|
6232
|
+
return errorResponse('AI plugin missing `proofread`.', 501);
|
|
6233
|
+
}
|
|
6234
|
+
const r = (await aiModule.proofread(body.text));
|
|
6235
|
+
result = { text: r.corrected, changes: r.changes };
|
|
6236
|
+
}
|
|
6237
|
+
}
|
|
6238
|
+
catch (err) {
|
|
6239
|
+
// Surface provider errors (missing API key, rate-limited upstream) with
|
|
6240
|
+
// a meaningful status code instead of 500.
|
|
6241
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
6242
|
+
if (msg.toLowerCase().includes('not configured') || msg.toLowerCase().includes('missing')) {
|
|
6243
|
+
return errorResponse(`AI provider is not configured: ${msg}`, 501);
|
|
6244
|
+
}
|
|
6245
|
+
throw err;
|
|
6246
|
+
}
|
|
6247
|
+
await logEvent({
|
|
6248
|
+
event: 'settings_changed',
|
|
6249
|
+
userId: auth.session.userId,
|
|
6250
|
+
details: {
|
|
6251
|
+
action: `ai_coauthor_${action}`,
|
|
6252
|
+
inputChars: body.text.length,
|
|
6253
|
+
outputChars: result.text.length,
|
|
6254
|
+
},
|
|
6255
|
+
});
|
|
6256
|
+
return json({ data: { action, ...result } });
|
|
6257
|
+
}
|
|
6258
|
+
catch (err) {
|
|
6259
|
+
return internalError(err, 'ai coauthor');
|
|
6260
|
+
}
|
|
6261
|
+
});
|
|
6262
|
+
router.post('/ai/suggest-internal-links', async (request) => {
|
|
6263
|
+
try {
|
|
6264
|
+
const auth = await requireAuth(request);
|
|
6265
|
+
if (auth.error)
|
|
6266
|
+
return auth.error;
|
|
6267
|
+
const body = (await request.json());
|
|
6268
|
+
if (!body.content || typeof body.content !== 'string') {
|
|
6269
|
+
return errorResponse('content is required', 400);
|
|
6270
|
+
}
|
|
6271
|
+
const limit = Math.max(1, Math.min(50, typeof body.limit === 'number' ? body.limit : 10));
|
|
6272
|
+
let aiModule;
|
|
6273
|
+
try {
|
|
6274
|
+
aiModule = await importAIPlugin();
|
|
6275
|
+
}
|
|
6276
|
+
catch {
|
|
6277
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use internal link suggestions.', 501);
|
|
6278
|
+
}
|
|
6279
|
+
if (typeof aiModule?.suggestInternalLinks !== 'function') {
|
|
6280
|
+
return errorResponse('AI plugin missing `suggestInternalLinks`. Upgrade @actuate-media/plugin-ai to >= 0.3.0.', 501);
|
|
6281
|
+
}
|
|
6282
|
+
const ctx = buildActionContext(auth.session, db());
|
|
6283
|
+
// Pull up to 200 published candidates from the requested collection
|
|
6284
|
+
// (or every readable collection when none is specified). We rely on
|
|
6285
|
+
// listDocuments' built-in access control so the agent can't surface
|
|
6286
|
+
// links to documents it isn't allowed to read.
|
|
6287
|
+
const cfg = getActuateConfig();
|
|
6288
|
+
const collectionSlugs = body.collection
|
|
6289
|
+
? [body.collection]
|
|
6290
|
+
: Object.keys(cfg?.collections ?? {});
|
|
6291
|
+
const pages = [];
|
|
6292
|
+
for (const slug of collectionSlugs) {
|
|
6293
|
+
const scopeErr = requireCollectionScope(auth.session, slug, 'read');
|
|
6294
|
+
if (scopeErr)
|
|
6295
|
+
continue;
|
|
6296
|
+
const listed = await listDocuments({ collection: slug, status: 'PUBLISHED', pageSize: 200 }, ctx);
|
|
6297
|
+
for (const doc of listed.docs) {
|
|
6298
|
+
if (body.excludeId && doc.id === body.excludeId)
|
|
6299
|
+
continue;
|
|
6300
|
+
const data = (doc.data ?? {});
|
|
6301
|
+
const titleStr = String(doc.title ?? data.title ?? data.name ?? '');
|
|
6302
|
+
if (!titleStr.trim())
|
|
6303
|
+
continue;
|
|
6304
|
+
const slugStr = String(doc.slug ?? data.slug ?? doc.id);
|
|
6305
|
+
const url = `/${slug}/${slugStr}`;
|
|
6306
|
+
const keywords = [];
|
|
6307
|
+
if (Array.isArray(data.keywords)) {
|
|
6308
|
+
for (const k of data.keywords) {
|
|
6309
|
+
if (typeof k === 'string' && k.trim())
|
|
6310
|
+
keywords.push(k);
|
|
6311
|
+
}
|
|
6312
|
+
}
|
|
6313
|
+
if (Array.isArray(data.tags)) {
|
|
6314
|
+
for (const k of data.tags) {
|
|
6315
|
+
if (typeof k === 'string' && k.trim())
|
|
6316
|
+
keywords.push(k);
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
pages.push({
|
|
6320
|
+
id: String(doc.id),
|
|
6321
|
+
title: titleStr,
|
|
6322
|
+
url,
|
|
6323
|
+
...(keywords.length > 0 ? { keywords } : {}),
|
|
6324
|
+
});
|
|
6325
|
+
}
|
|
6326
|
+
}
|
|
6327
|
+
const suggestions = await aiModule.suggestInternalLinks(body.content, pages);
|
|
6328
|
+
const limited = suggestions.slice(0, limit);
|
|
6329
|
+
return json({
|
|
6330
|
+
data: {
|
|
6331
|
+
suggestions: limited,
|
|
6332
|
+
candidatesScanned: pages.length,
|
|
6333
|
+
},
|
|
6334
|
+
});
|
|
6335
|
+
}
|
|
6336
|
+
catch (err) {
|
|
6337
|
+
return internalError(err, 'ai suggest-internal-links');
|
|
6338
|
+
}
|
|
6339
|
+
});
|
|
5795
6340
|
}
|
|
5796
6341
|
//# sourceMappingURL=handlers.js.map
|