@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.
@@ -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
- function siteUrlFromRequest(request) {
3242
- const cfg = getActuateConfig()?.seo;
3243
- if (cfg?.siteUrl)
3244
- return cfg.siteUrl.replace(/\/+$/, '');
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 cfg = getActuateConfig();
3257
- if (!cfg)
3411
+ function sitemapEligibleCollections(cfg) {
3412
+ const resolved = cfg ?? getActuateConfig();
3413
+ if (!resolved)
3258
3414
  return [];
3259
- const excluded = new Set(cfg.seo?.sitemap?.excludeCollections ?? []);
3415
+ const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
3260
3416
  const out = [];
3261
- for (const [slug, col] of Object.entries(cfg.collections ?? {})) {
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 = getActuateConfig();
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 = getActuateConfig();
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 = getActuateConfig();
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 = getActuateConfig();
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
- const cfg = getActuateConfig();
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