@actuate-media/cms-core 0.57.0 → 0.58.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.
Files changed (214) hide show
  1. package/dist/__tests__/api/stats-seo-cache.test.js +1 -1
  2. package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -1
  3. package/dist/__tests__/auth/oauth.test.js +3 -1
  4. package/dist/__tests__/auth/oauth.test.js.map +1 -1
  5. package/dist/__tests__/auth/reset.test.js +1 -0
  6. package/dist/__tests__/auth/reset.test.js.map +1 -1
  7. package/dist/__tests__/auth/user-admin.test.js +14 -12
  8. package/dist/__tests__/auth/user-admin.test.js.map +1 -1
  9. package/dist/__tests__/db/model-types-freshness.test.d.ts +2 -0
  10. package/dist/__tests__/db/model-types-freshness.test.d.ts.map +1 -0
  11. package/dist/__tests__/db/model-types-freshness.test.js +46 -0
  12. package/dist/__tests__/db/model-types-freshness.test.js.map +1 -0
  13. package/dist/__tests__/scheduling/scheduling.test.js +8 -6
  14. package/dist/__tests__/scheduling/scheduling.test.js.map +1 -1
  15. package/dist/__tests__/server-site.test.js +3 -1
  16. package/dist/__tests__/server-site.test.js.map +1 -1
  17. package/dist/__tests__/setup/index.test.js +21 -10
  18. package/dist/__tests__/setup/index.test.js.map +1 -1
  19. package/dist/actions.d.ts +2 -1
  20. package/dist/actions.d.ts.map +1 -1
  21. package/dist/actions.js +15 -8
  22. package/dist/actions.js.map +1 -1
  23. package/dist/api/guarded-db.d.ts +14 -0
  24. package/dist/api/guarded-db.d.ts.map +1 -0
  25. package/dist/api/guarded-db.js +58 -0
  26. package/dist/api/guarded-db.js.map +1 -0
  27. package/dist/api/handler-factory.d.ts.map +1 -1
  28. package/dist/api/handler-factory.js +2 -1
  29. package/dist/api/handler-factory.js.map +1 -1
  30. package/dist/api/handlers.d.ts +5 -3
  31. package/dist/api/handlers.d.ts.map +1 -1
  32. package/dist/api/handlers.js +54 -13068
  33. package/dist/api/handlers.js.map +1 -1
  34. package/dist/api/route-helpers.d.ts +321 -0
  35. package/dist/api/route-helpers.d.ts.map +1 -0
  36. package/dist/api/route-helpers.js +1307 -0
  37. package/dist/api/route-helpers.js.map +1 -0
  38. package/dist/api/routes/ai-seo.d.ts +3 -0
  39. package/dist/api/routes/ai-seo.d.ts.map +1 -0
  40. package/dist/api/routes/ai-seo.js +377 -0
  41. package/dist/api/routes/ai-seo.js.map +1 -0
  42. package/dist/api/routes/ai-settings.d.ts +3 -0
  43. package/dist/api/routes/ai-settings.d.ts.map +1 -0
  44. package/dist/api/routes/ai-settings.js +764 -0
  45. package/dist/api/routes/ai-settings.js.map +1 -0
  46. package/dist/api/routes/auth.d.ts +3 -0
  47. package/dist/api/routes/auth.d.ts.map +1 -0
  48. package/dist/api/routes/auth.js +787 -0
  49. package/dist/api/routes/auth.js.map +1 -0
  50. package/dist/api/routes/collab.d.ts +3 -0
  51. package/dist/api/routes/collab.d.ts.map +1 -0
  52. package/dist/api/routes/collab.js +426 -0
  53. package/dist/api/routes/collab.js.map +1 -0
  54. package/dist/api/routes/dashboard.d.ts +7 -0
  55. package/dist/api/routes/dashboard.d.ts.map +1 -0
  56. package/dist/api/routes/dashboard.js +540 -0
  57. package/dist/api/routes/dashboard.js.map +1 -0
  58. package/dist/api/routes/documents.d.ts +3 -0
  59. package/dist/api/routes/documents.d.ts.map +1 -0
  60. package/dist/api/routes/documents.js +180 -0
  61. package/dist/api/routes/documents.js.map +1 -0
  62. package/dist/api/routes/folders.d.ts +3 -0
  63. package/dist/api/routes/folders.d.ts.map +1 -0
  64. package/dist/api/routes/folders.js +195 -0
  65. package/dist/api/routes/folders.js.map +1 -0
  66. package/dist/api/routes/forms.d.ts +3 -0
  67. package/dist/api/routes/forms.d.ts.map +1 -0
  68. package/dist/api/routes/forms.js +661 -0
  69. package/dist/api/routes/forms.js.map +1 -0
  70. package/dist/api/routes/globals.d.ts +3 -0
  71. package/dist/api/routes/globals.d.ts.map +1 -0
  72. package/dist/api/routes/globals.js +133 -0
  73. package/dist/api/routes/globals.js.map +1 -0
  74. package/dist/api/routes/media.d.ts +3 -0
  75. package/dist/api/routes/media.d.ts.map +1 -0
  76. package/dist/api/routes/media.js +521 -0
  77. package/dist/api/routes/media.js.map +1 -0
  78. package/dist/api/routes/meta.d.ts +3 -0
  79. package/dist/api/routes/meta.d.ts.map +1 -0
  80. package/dist/api/routes/meta.js +96 -0
  81. package/dist/api/routes/meta.js.map +1 -0
  82. package/dist/api/routes/page-templates.d.ts +3 -0
  83. package/dist/api/routes/page-templates.d.ts.map +1 -0
  84. package/dist/api/routes/page-templates.js +265 -0
  85. package/dist/api/routes/page-templates.js.map +1 -0
  86. package/dist/api/routes/presence.d.ts +3 -0
  87. package/dist/api/routes/presence.d.ts.map +1 -0
  88. package/dist/api/routes/presence.js +35 -0
  89. package/dist/api/routes/presence.js.map +1 -0
  90. package/dist/api/routes/preview.d.ts +3 -0
  91. package/dist/api/routes/preview.d.ts.map +1 -0
  92. package/dist/api/routes/preview.js +111 -0
  93. package/dist/api/routes/preview.js.map +1 -0
  94. package/dist/api/routes/redirects.d.ts +3 -0
  95. package/dist/api/routes/redirects.d.ts.map +1 -0
  96. package/dist/api/routes/redirects.js +790 -0
  97. package/dist/api/routes/redirects.js.map +1 -0
  98. package/dist/api/routes/saved-sections.d.ts +3 -0
  99. package/dist/api/routes/saved-sections.d.ts.map +1 -0
  100. package/dist/api/routes/saved-sections.js +1036 -0
  101. package/dist/api/routes/saved-sections.js.map +1 -0
  102. package/dist/api/routes/scheduling.d.ts +3 -0
  103. package/dist/api/routes/scheduling.d.ts.map +1 -0
  104. package/dist/api/routes/scheduling.js +373 -0
  105. package/dist/api/routes/scheduling.js.map +1 -0
  106. package/dist/api/routes/sections.d.ts +3 -0
  107. package/dist/api/routes/sections.d.ts.map +1 -0
  108. package/dist/api/routes/sections.js +500 -0
  109. package/dist/api/routes/sections.js.map +1 -0
  110. package/dist/api/routes/security-center.d.ts +3 -0
  111. package/dist/api/routes/security-center.d.ts.map +1 -0
  112. package/dist/api/routes/security-center.js +71 -0
  113. package/dist/api/routes/security-center.js.map +1 -0
  114. package/dist/api/routes/seo-public.d.ts +14 -0
  115. package/dist/api/routes/seo-public.d.ts.map +1 -0
  116. package/dist/api/routes/seo-public.js +930 -0
  117. package/dist/api/routes/seo-public.js.map +1 -0
  118. package/dist/api/routes/seo.d.ts +8 -0
  119. package/dist/api/routes/seo.d.ts.map +1 -0
  120. package/dist/api/routes/seo.js +848 -0
  121. package/dist/api/routes/seo.js.map +1 -0
  122. package/dist/api/routes/system.d.ts +3 -0
  123. package/dist/api/routes/system.d.ts.map +1 -0
  124. package/dist/api/routes/system.js +340 -0
  125. package/dist/api/routes/system.js.map +1 -0
  126. package/dist/api/routes/url-resolution.d.ts +3 -0
  127. package/dist/api/routes/url-resolution.d.ts.map +1 -0
  128. package/dist/api/routes/url-resolution.js +457 -0
  129. package/dist/api/routes/url-resolution.js.map +1 -0
  130. package/dist/api/routes/users.d.ts +3 -0
  131. package/dist/api/routes/users.d.ts.map +1 -0
  132. package/dist/api/routes/users.js +1162 -0
  133. package/dist/api/routes/users.js.map +1 -0
  134. package/dist/api/routes/webhooks.d.ts +3 -0
  135. package/dist/api/routes/webhooks.d.ts.map +1 -0
  136. package/dist/api/routes/webhooks.js +338 -0
  137. package/dist/api/routes/webhooks.js.map +1 -0
  138. package/dist/auth/invites.d.ts +9 -8
  139. package/dist/auth/invites.d.ts.map +1 -1
  140. package/dist/auth/invites.js.map +1 -1
  141. package/dist/auth/oauth.d.ts +2 -1
  142. package/dist/auth/oauth.d.ts.map +1 -1
  143. package/dist/auth/oauth.js.map +1 -1
  144. package/dist/auth/reset.d.ts +3 -2
  145. package/dist/auth/reset.d.ts.map +1 -1
  146. package/dist/auth/reset.js +5 -2
  147. package/dist/auth/reset.js.map +1 -1
  148. package/dist/auth/session.d.ts +3 -2
  149. package/dist/auth/session.d.ts.map +1 -1
  150. package/dist/auth/session.js.map +1 -1
  151. package/dist/auth/team.d.ts +4 -3
  152. package/dist/auth/team.d.ts.map +1 -1
  153. package/dist/auth/team.js.map +1 -1
  154. package/dist/auth/user-admin.d.ts +6 -5
  155. package/dist/auth/user-admin.d.ts.map +1 -1
  156. package/dist/auth/user-admin.js.map +1 -1
  157. package/dist/content-health/cron.d.ts +2 -1
  158. package/dist/content-health/cron.d.ts.map +1 -1
  159. package/dist/content-health/cron.js.map +1 -1
  160. package/dist/cron/index.d.ts +2 -1
  161. package/dist/cron/index.d.ts.map +1 -1
  162. package/dist/cron/index.js +2 -0
  163. package/dist/cron/index.js.map +1 -1
  164. package/dist/db/client-types.d.ts +75 -0
  165. package/dist/db/client-types.d.ts.map +1 -0
  166. package/dist/db/client-types.js +2 -0
  167. package/dist/db/client-types.js.map +1 -0
  168. package/dist/db/model-types.d.ts +564 -0
  169. package/dist/db/model-types.d.ts.map +1 -0
  170. package/dist/db/model-types.js +14 -0
  171. package/dist/db/model-types.js.map +1 -0
  172. package/dist/diagnostics/api-metrics.d.ts.map +1 -1
  173. package/dist/diagnostics/api-metrics.js.map +1 -1
  174. package/dist/forms/entries.d.ts.map +1 -1
  175. package/dist/forms/entries.js.map +1 -1
  176. package/dist/forms/files.d.ts.map +1 -1
  177. package/dist/forms/files.js.map +1 -1
  178. package/dist/forms/service.d.ts.map +1 -1
  179. package/dist/forms/service.js.map +1 -1
  180. package/dist/forms/submission.d.ts.map +1 -1
  181. package/dist/forms/submission.js.map +1 -1
  182. package/dist/forms/webhooks.d.ts.map +1 -1
  183. package/dist/forms/webhooks.js.map +1 -1
  184. package/dist/graphql/index.d.ts.map +1 -1
  185. package/dist/graphql/index.js.map +1 -1
  186. package/dist/index.d.ts +1 -0
  187. package/dist/index.d.ts.map +1 -1
  188. package/dist/index.js.map +1 -1
  189. package/dist/scheduling/index.d.ts +2 -1
  190. package/dist/scheduling/index.d.ts.map +1 -1
  191. package/dist/scheduling/index.js.map +1 -1
  192. package/dist/search/index.d.ts.map +1 -1
  193. package/dist/search/index.js.map +1 -1
  194. package/dist/security/audit.d.ts.map +1 -1
  195. package/dist/security/audit.js.map +1 -1
  196. package/dist/security/reauth.d.ts +2 -1
  197. package/dist/security/reauth.d.ts.map +1 -1
  198. package/dist/security/reauth.js.map +1 -1
  199. package/dist/seo/audit-runner.d.ts +3 -2
  200. package/dist/seo/audit-runner.d.ts.map +1 -1
  201. package/dist/seo/audit-runner.js.map +1 -1
  202. package/dist/server-site.d.ts +2 -1
  203. package/dist/server-site.d.ts.map +1 -1
  204. package/dist/server-site.js.map +1 -1
  205. package/dist/setup/index.d.ts +4 -3
  206. package/dist/setup/index.d.ts.map +1 -1
  207. package/dist/setup/index.js.map +1 -1
  208. package/dist/webhooks/index.d.ts +5 -5
  209. package/dist/webhooks/index.d.ts.map +1 -1
  210. package/dist/webhooks/index.js +8 -1
  211. package/dist/webhooks/index.js.map +1 -1
  212. package/dist/workflow/index.d.ts.map +1 -1
  213. package/dist/workflow/index.js.map +1 -1
  214. package/package.json +1 -1
@@ -0,0 +1,930 @@
1
+ import { logEvent } from '../../security/audit.js';
2
+ import { safeFetch, SsrfBlockedError } from '../../security/safe-fetch.js';
3
+ import { getActuateConfig } from '../../config/runtime.js';
4
+ import { guardedDb } from '../guarded-db.js';
5
+ import { computeLightSeoScore } from './dashboard.js';
6
+ import { json, errorResponse, internalError, escapeXml, SITEMAP_PAGE_SIZE, renderOgSvg, safeFindMany, requireAuth, WRITE_ROLES, ADMIN_ROLES, requireRole, enforceDocumentReadAccess, } from '../route-helpers.js';
7
+ /**
8
+ * Load the runtime config with DB-stored SEO overrides applied. Used by every
9
+ * public SEO surface (sitemap, robots, og.png) and by /resolve so admin edits
10
+ * in the Settings → SEO panel take effect without a redeploy.
11
+ *
12
+ * Falls back to the static config when no override row exists yet — first
13
+ * boot of a freshly-installed CMS keeps working.
14
+ */
15
+ export async function loadEffectiveConfig() {
16
+ const base = getActuateConfig();
17
+ if (!base)
18
+ return null;
19
+ try {
20
+ const { getSeoOverrides, applySeoOverrides } = await import('../../seo/config-store.js');
21
+ const overrides = await getSeoOverrides(guardedDb());
22
+ return applySeoOverrides(base, overrides);
23
+ }
24
+ catch {
25
+ return base;
26
+ }
27
+ }
28
+ export function siteUrlFromRequest(request, cfg) {
29
+ const seo = (cfg ?? getActuateConfig())?.seo;
30
+ if (seo?.siteUrl)
31
+ return seo.siteUrl.replace(/\/+$/, '');
32
+ // Fall back to the request origin so the routes work on preview deploys
33
+ // before the integrator has configured siteUrl.
34
+ try {
35
+ const u = new URL(request.url);
36
+ return `${u.protocol}//${u.host}`;
37
+ }
38
+ catch {
39
+ return '';
40
+ }
41
+ }
42
+ export function registerSeoPublicRoutes(router) {
43
+ const db = guardedDb;
44
+ // ---------------------------------------------------------------------------
45
+ // Public SEO surfaces: sitemap.xml, per-collection sitemaps, robots.txt,
46
+ // and the dynamic /og.png OG-image endpoint. These are unauthenticated by
47
+ // design — search engines and social crawlers will fetch them.
48
+ // ---------------------------------------------------------------------------
49
+ function sitemapEligibleCollections(cfg) {
50
+ const resolved = cfg ?? getActuateConfig();
51
+ if (!resolved)
52
+ return [];
53
+ const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
54
+ const out = [];
55
+ for (const [slug, col] of Object.entries(resolved.collections ?? {})) {
56
+ // Only publicly-rendered content types belong in the sitemap. Structural
57
+ // / utility collections (navigation menus, footers, tags, templates) have
58
+ // no `type` and must never produce indexable sitemap URLs.
59
+ if (col.type !== 'page' && col.type !== 'post')
60
+ continue;
61
+ if (col.admin?.hidden)
62
+ continue;
63
+ if (excluded.has(slug))
64
+ continue;
65
+ if (col.seo?.excludeFromSitemap)
66
+ continue;
67
+ out.push({ slug, urlPrefix: col.urlPrefix, type: col.type, seo: col.seo });
68
+ }
69
+ return out;
70
+ }
71
+ router.get('/sitemap.xml', async (request) => {
72
+ try {
73
+ const cfg = await loadEffectiveConfig();
74
+ if (cfg?.seo?.sitemap?.disabled)
75
+ return errorResponse('Sitemap disabled', 404);
76
+ const base = siteUrlFromRequest(request, cfg);
77
+ const cols = sitemapEligibleCollections(cfg);
78
+ // Sitemap index points at /sitemaps/:slug.xml for each collection. A
79
+ // collection with more than SITEMAP_PAGE_SIZE published docs is split
80
+ // across `?page=N` files so we never silently truncate (the old code
81
+ // capped at 5,000 and dropped the rest).
82
+ const now = new Date().toISOString();
83
+ const perCollection = await Promise.all(cols.map(async (c) => {
84
+ let count = 0;
85
+ try {
86
+ count = await db().document.count({
87
+ where: { collection: c.slug, deletedAt: null, status: 'PUBLISHED' },
88
+ });
89
+ }
90
+ catch {
91
+ count = 0;
92
+ }
93
+ const pages = Math.max(1, Math.ceil(count / SITEMAP_PAGE_SIZE));
94
+ const entries = [];
95
+ for (let page = 0; page < pages; page++) {
96
+ const loc = page === 0
97
+ ? `${base}/api/cms/sitemaps/${c.slug}.xml`
98
+ : `${base}/api/cms/sitemaps/${c.slug}.xml?page=${page}`;
99
+ entries.push([
100
+ ' <sitemap>',
101
+ ` <loc>${escapeXml(loc)}</loc>`,
102
+ ` <lastmod>${now}</lastmod>`,
103
+ ' </sitemap>',
104
+ ].join('\n'));
105
+ }
106
+ return entries.join('\n');
107
+ }));
108
+ const sitemaps = perCollection.filter(Boolean).join('\n');
109
+ const xml = [
110
+ '<?xml version="1.0" encoding="UTF-8"?>',
111
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
112
+ sitemaps,
113
+ '</sitemapindex>',
114
+ ].join('\n');
115
+ return new Response(xml, {
116
+ status: 200,
117
+ headers: {
118
+ 'Content-Type': 'application/xml; charset=utf-8',
119
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
120
+ },
121
+ });
122
+ }
123
+ catch (err) {
124
+ return internalError(err, 'sitemap.xml');
125
+ }
126
+ });
127
+ // The router's path-param syntax (`:name`) greedily matches everything up
128
+ // to the next `/`, so registering as `/sitemaps/:slug.xml` would capture
129
+ // `slug.xml` into a single param. We register `/sitemaps/:slug` instead and
130
+ // require the `.xml` suffix in the handler.
131
+ router.get('/sitemaps/:slug', async (request, params) => {
132
+ try {
133
+ const cfg = await loadEffectiveConfig();
134
+ if (cfg?.seo?.sitemap?.disabled)
135
+ return errorResponse('Sitemap disabled', 404);
136
+ const slugParam = params.slug ?? '';
137
+ if (!/\.xml$/i.test(slugParam))
138
+ return errorResponse('Sitemap not found', 404);
139
+ const rawSlug = slugParam.replace(/\.xml$/i, '');
140
+ const collection = cfg?.collections?.[rawSlug];
141
+ if (!collection)
142
+ return errorResponse('Collection not found', 404);
143
+ // Only publicly-rendered page/post collections are sitemap-eligible —
144
+ // structural/utility collections (menus, tags, templates) are not.
145
+ if (collection.type !== 'page' && collection.type !== 'post')
146
+ return errorResponse('Collection excluded from sitemap', 404);
147
+ if (collection.admin?.hidden)
148
+ return errorResponse('Collection excluded from sitemap', 404);
149
+ if (collection.seo?.excludeFromSitemap)
150
+ return errorResponse('Collection excluded from sitemap', 404);
151
+ const base = siteUrlFromRequest(request, cfg);
152
+ const pageParam = Number(new URL(request.url).searchParams.get('page') ?? '0');
153
+ const page = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 0;
154
+ const docs = await db().document.findMany({
155
+ where: { collection: rawSlug, deletedAt: null, status: 'PUBLISHED' },
156
+ select: { slug: true, updatedAt: true, data: true },
157
+ orderBy: { updatedAt: 'desc' },
158
+ skip: page * SITEMAP_PAGE_SIZE,
159
+ take: SITEMAP_PAGE_SIZE,
160
+ });
161
+ const prefix = (collection.urlPrefix ?? '').replace(/^\/|\/$/g, '');
162
+ const defaultPriority = collection.seo?.sitemapPriority ??
163
+ cfg?.seo?.sitemap?.defaultPriority ??
164
+ (collection.type === 'page' ? 0.8 : 0.6);
165
+ const changefreq = collection.seo?.sitemapChangeFreq ?? cfg?.seo?.sitemap?.defaultChangeFreq ?? 'weekly';
166
+ const urls = [];
167
+ // Archive page first (e.g. /blog) for post-type collections — only on
168
+ // the first page so it isn't duplicated across paginated sitemap files.
169
+ if (page === 0 && collection.seo?.archivePath && collection.type === 'post') {
170
+ const archive = collection.seo.archivePath.startsWith('http')
171
+ ? collection.seo.archivePath
172
+ : `${base}${collection.seo.archivePath}`;
173
+ urls.push([
174
+ ' <url>',
175
+ ` <loc>${escapeXml(archive)}</loc>`,
176
+ ` <lastmod>${new Date().toISOString()}</lastmod>`,
177
+ ` <changefreq>${changefreq}</changefreq>`,
178
+ ' <priority>0.6</priority>',
179
+ ' </url>',
180
+ ].join('\n'));
181
+ }
182
+ for (const d of docs) {
183
+ const data = d.data || {};
184
+ const slug = d.slug ?? data.slug;
185
+ if (!slug)
186
+ continue;
187
+ const loc = prefix ? `${base}/${prefix}/${slug}` : `${base}/${slug}`;
188
+ urls.push([
189
+ ' <url>',
190
+ ` <loc>${escapeXml(loc)}</loc>`,
191
+ ` <lastmod>${d.updatedAt?.toISOString() ?? new Date().toISOString()}</lastmod>`,
192
+ ` <changefreq>${changefreq}</changefreq>`,
193
+ ` <priority>${defaultPriority.toFixed(1)}</priority>`,
194
+ ' </url>',
195
+ ].join('\n'));
196
+ }
197
+ const xml = [
198
+ '<?xml version="1.0" encoding="UTF-8"?>',
199
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
200
+ urls.join('\n'),
201
+ '</urlset>',
202
+ ].join('\n');
203
+ return new Response(xml, {
204
+ status: 200,
205
+ headers: {
206
+ 'Content-Type': 'application/xml; charset=utf-8',
207
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
208
+ },
209
+ });
210
+ }
211
+ catch (err) {
212
+ return internalError(err, 'sitemaps/:slug.xml');
213
+ }
214
+ });
215
+ router.get('/robots.txt', async (request) => {
216
+ try {
217
+ const cfg = await loadEffectiveConfig();
218
+ const seo = cfg?.seo;
219
+ if (seo?.robots?.disabled)
220
+ return errorResponse('robots.txt disabled', 404);
221
+ // Admin-edited custom robots.txt body (Technical tab) wins when set —
222
+ // the editor is the single source of truth for operators who want full
223
+ // control over directives.
224
+ try {
225
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
226
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
227
+ if (settings.robotsEnabled === false)
228
+ return errorResponse('robots.txt disabled', 404);
229
+ const custom = settings.robotsTxt?.trim();
230
+ if (custom) {
231
+ return new Response(settings.robotsTxt, {
232
+ status: 200,
233
+ headers: {
234
+ 'Content-Type': 'text/plain; charset=utf-8',
235
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
236
+ },
237
+ });
238
+ }
239
+ }
240
+ catch {
241
+ // Fall through to generated defaults on any override-read failure.
242
+ }
243
+ const base = siteUrlFromRequest(request, cfg);
244
+ const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
245
+ const lines = [
246
+ 'User-agent: *',
247
+ 'Allow: /',
248
+ 'Disallow: /admin',
249
+ 'Disallow: /api/',
250
+ '',
251
+ ];
252
+ for (const rule of seo?.robots?.additionalRules ?? []) {
253
+ lines.push(`User-agent: ${rule.userAgent}`);
254
+ for (const allow of rule.allow ?? [])
255
+ lines.push(`Allow: ${allow}`);
256
+ for (const dis of rule.disallow ?? [])
257
+ lines.push(`Disallow: ${dis}`);
258
+ lines.push('');
259
+ }
260
+ if (seo?.robots?.blockAIBots) {
261
+ const bots = [
262
+ 'GPTBot',
263
+ 'ChatGPT-User',
264
+ 'ClaudeBot',
265
+ 'Claude-Web',
266
+ 'anthropic-ai',
267
+ 'Bytespider',
268
+ 'CCBot',
269
+ 'Google-Extended',
270
+ ];
271
+ for (const bot of bots) {
272
+ lines.push(`User-agent: ${bot}`);
273
+ lines.push('Disallow: /');
274
+ lines.push('');
275
+ }
276
+ }
277
+ if (sitemapUrl)
278
+ lines.push(`Sitemap: ${sitemapUrl}`, '');
279
+ return new Response(lines.join('\n'), {
280
+ status: 200,
281
+ headers: {
282
+ 'Content-Type': 'text/plain; charset=utf-8',
283
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
284
+ },
285
+ });
286
+ }
287
+ catch (err) {
288
+ return internalError(err, 'robots.txt');
289
+ }
290
+ });
291
+ router.get('/og.png', async (request) => {
292
+ try {
293
+ const cfg = await loadEffectiveConfig();
294
+ if (cfg?.seo?.ogImage?.disabled)
295
+ return errorResponse('og.png disabled', 404);
296
+ const url = new URL(request.url);
297
+ const title = url.searchParams.get('title') ?? cfg?.seo?.siteName ?? 'Untitled';
298
+ const description = url.searchParams.get('description') ?? undefined;
299
+ const siteName = url.searchParams.get('siteName') ?? cfg?.seo?.siteName;
300
+ const theme = url.searchParams.get('theme') ?? cfg?.seo?.ogImage?.theme ?? 'light';
301
+ const bg = theme === 'dark' ? '#0a0a0a' : '#ffffff';
302
+ const fg = theme === 'dark' ? '#fafafa' : '#0a0a0a';
303
+ const muted = theme === 'dark' ? '#a1a1aa' : '#71717a';
304
+ // SVG OG image. Crawlers (Facebook, Twitter/X, LinkedIn, Slack, Discord)
305
+ // all accept image/svg+xml when served with the right Content-Type. We
306
+ // chose SVG over PNG to avoid forcing a Satori/resvg dependency on
307
+ // every integrator; sites that need PNG can override with `og:image`
308
+ // pointing at their own /og endpoint built with @vercel/og.
309
+ const svg = renderOgSvg({ title, description, siteName, theme, bg, fg, muted });
310
+ return new Response(svg, {
311
+ status: 200,
312
+ headers: {
313
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
314
+ 'Cache-Control': 'public, max-age=86400, s-maxage=86400',
315
+ },
316
+ });
317
+ }
318
+ catch (err) {
319
+ return internalError(err, 'og.png');
320
+ }
321
+ });
322
+ router.get('/seo/schema/:documentId', async (request, params) => {
323
+ try {
324
+ const auth = await requireAuth(request);
325
+ if (auth.error)
326
+ return auth.error;
327
+ const doc = await db().document.findFirst({
328
+ where: { id: params.documentId, deletedAt: null },
329
+ });
330
+ if (!doc)
331
+ return errorResponse('Not found', 404);
332
+ const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
333
+ if (accessErr)
334
+ return accessErr;
335
+ const { buildSchemaGraph } = await import('../../content/structured-data.js');
336
+ const data = doc.data || {};
337
+ const graph = buildSchemaGraph({
338
+ siteName: 'Actuate CMS',
339
+ siteUrl: '',
340
+ }, {
341
+ url: `/${doc.collection}/${doc.slug || ''}`,
342
+ title: doc.title || data.title || '',
343
+ description: data.metaDescription || data.seoDescription || '',
344
+ datePublished: doc.createdAt?.toISOString(),
345
+ dateModified: doc.updatedAt?.toISOString(),
346
+ type: doc.collection === 'posts' ? 'post' : 'page',
347
+ keywords: data.tags || data.keywords,
348
+ featuredImage: data.featuredImage ? { url: data.featuredImage } : undefined,
349
+ });
350
+ return new Response(JSON.stringify(graph), {
351
+ status: 200,
352
+ headers: { 'Content-Type': 'application/json' },
353
+ });
354
+ }
355
+ catch (err) {
356
+ return internalError(err, 'seo/schema');
357
+ }
358
+ });
359
+ router.get('/seo/meta/:documentId', async (request, params) => {
360
+ try {
361
+ const auth = await requireAuth(request);
362
+ if (auth.error)
363
+ return auth.error;
364
+ const doc = await db().document.findFirst({
365
+ where: { id: params.documentId, deletedAt: null },
366
+ });
367
+ if (!doc)
368
+ return errorResponse('Not found', 404);
369
+ const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
370
+ if (accessErr)
371
+ return accessErr;
372
+ const { generateMetaTags } = await import('../../seo/meta-tags.js');
373
+ const { resolveRobotsDirectives } = await import('../../seo/robots.js');
374
+ const { getSeoOverrides, applySeoOverrides } = await import('../../seo/config-store.js');
375
+ const data = doc.data || {};
376
+ // Read robots defaults from the SAME effective SEO config the public
377
+ // renderer (`composePageMeta`) and General Settings use — never the
378
+ // legacy `settings` global — so this preview can't drift from reality.
379
+ const effectiveSeo = applySeoOverrides(getActuateConfig(), await getSeoOverrides(db()))?.seo;
380
+ const robots = resolveRobotsDirectives({
381
+ pageData: data,
382
+ settings: {
383
+ defaultNoIndex: effectiveSeo?.robots?.defaultNoIndex,
384
+ defaultNoFollow: effectiveSeo?.robots?.defaultNoFollow,
385
+ noIndexNonProduction: effectiveSeo?.robots?.noIndexNonProduction,
386
+ },
387
+ environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
388
+ });
389
+ const tags = generateMetaTags({
390
+ title: data.metaTitle || doc.title || data.title || '',
391
+ description: data.metaDescription || data.seoDescription || '',
392
+ url: `/${doc.collection}/${doc.slug || ''}`,
393
+ siteName: 'Actuate CMS',
394
+ type: doc.collection === 'posts' ? 'article' : 'website',
395
+ ogTitle: data.ogTitle,
396
+ ogDescription: data.ogDescription,
397
+ ogImage: data.ogImage ? { url: data.ogImage } : undefined,
398
+ twitterTitle: data.twitterTitle,
399
+ twitterDescription: data.twitterDescription,
400
+ twitterImage: data.twitterImage,
401
+ publishedTime: doc.createdAt?.toISOString(),
402
+ modifiedTime: doc.updatedAt?.toISOString(),
403
+ noIndex: robots.noIndex,
404
+ noFollow: robots.noFollow,
405
+ canonical: data.canonical,
406
+ });
407
+ return new Response(JSON.stringify(tags), {
408
+ status: 200,
409
+ headers: { 'Content-Type': 'application/json' },
410
+ });
411
+ }
412
+ catch (err) {
413
+ return internalError(err, 'seo/meta');
414
+ }
415
+ });
416
+ // ---------------------------------------------------------------------------
417
+ // SEO summary (aggregated dashboard data)
418
+ // ---------------------------------------------------------------------------
419
+ router.get('/seo/summary', async (request) => {
420
+ try {
421
+ const auth = await requireAuth(request);
422
+ if (auth.error)
423
+ return auth.error;
424
+ // EDITOR+ only — this endpoint issues outbound HEAD requests for link
425
+ // health, mirroring the role gate on /seo/link-health.
426
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
427
+ if (roleErr)
428
+ return roleErr;
429
+ const docs = (await safeFindMany(db().document, {
430
+ where: { deletedAt: null, status: 'PUBLISHED' },
431
+ select: { id: true, title: true, collection: true, data: true, plainText: true },
432
+ orderBy: { updatedAt: 'desc' },
433
+ take: 50,
434
+ }));
435
+ let missingMetaDescriptions = 0;
436
+ let missingAltText = 0;
437
+ const topContent = [];
438
+ for (const doc of docs) {
439
+ const data = doc.data ?? {};
440
+ if (!data.metaDescription && !data.seoDescription)
441
+ missingMetaDescriptions++;
442
+ const content = typeof data.body === 'string'
443
+ ? data.body
444
+ : typeof data.content === 'string'
445
+ ? data.content
446
+ : '';
447
+ if (content) {
448
+ const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
449
+ const noAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
450
+ if (noAlt > 0)
451
+ missingAltText += noAlt;
452
+ }
453
+ topContent.push({
454
+ id: doc.id,
455
+ title: doc.title ?? 'Untitled',
456
+ collection: doc.collection,
457
+ score: computeLightSeoScore(data),
458
+ });
459
+ }
460
+ topContent.sort((a, b) => b.score - a.score);
461
+ let brokenInternalLinks = 0;
462
+ try {
463
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
464
+ // Only probe internal links (same-origin as the configured site). When
465
+ // NEXT_PUBLIC_SITE_URL is unset we can't classify any URL as internal,
466
+ // so we skip all probing rather than issuing HEAD requests to arbitrary,
467
+ // document-controlled hosts — that would be an SSRF oracle.
468
+ if (siteUrl) {
469
+ const linkDocs = docs.slice(0, 10);
470
+ const urlRegex = /https?:\/\/[^\s"'<>]+/g;
471
+ for (const doc of linkDocs) {
472
+ const content = JSON.stringify(doc.data ?? {});
473
+ const urls = content.match(urlRegex) ?? [];
474
+ const seen = new Set();
475
+ for (const url of urls) {
476
+ const clean = url.replace(/[",;)}\]]+$/, '');
477
+ if (seen.has(clean) || !clean.startsWith(siteUrl))
478
+ continue;
479
+ seen.add(clean);
480
+ try {
481
+ // safeFetch rejects private/loopback IPs and disables redirect
482
+ // following, so even an internal link can't smuggle the probe
483
+ // onto the internal network.
484
+ const resp = await safeFetch(clean, { method: 'HEAD', timeoutMs: 3000 });
485
+ try {
486
+ await resp.body?.cancel();
487
+ }
488
+ catch {
489
+ /* noop */
490
+ }
491
+ if (resp.status >= 400)
492
+ brokenInternalLinks++;
493
+ }
494
+ catch (err) {
495
+ if (err instanceof SsrfBlockedError)
496
+ continue;
497
+ brokenInternalLinks++;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ catch {
504
+ /* best effort */
505
+ }
506
+ return json({
507
+ data: {
508
+ totalPages: docs.length,
509
+ issuesSummary: { missingMetaDescriptions, brokenInternalLinks, missingAltText },
510
+ topContent: topContent.slice(0, 10),
511
+ },
512
+ });
513
+ }
514
+ catch (err) {
515
+ return internalError(err);
516
+ }
517
+ });
518
+ // ---------------------------------------------------------------------------
519
+ // SEO config (admin-editable overrides for `actuate.config.ts` seo defaults)
520
+ // ---------------------------------------------------------------------------
521
+ /**
522
+ * Returns:
523
+ * - `static` → the SEO config from `actuate.config.ts` (read-only, code-level)
524
+ * - `overrides` → the user-edited DB overrides (or null on first boot)
525
+ * - `effective` → static + overrides merged (what the runtime actually uses)
526
+ * - `collections` → list of collection slugs + labels the UI should iterate
527
+ *
528
+ * The UI uses `static` to show grey "default" placeholders, `overrides` to
529
+ * populate the form inputs, and writes back just the changed fields via PUT.
530
+ */
531
+ router.get('/seo/config', async (request) => {
532
+ try {
533
+ const auth = await requireAuth(request);
534
+ if (auth.error)
535
+ return auth.error;
536
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
537
+ if (roleErr)
538
+ return roleErr;
539
+ const { getSeoOverrides, applySeoOverrides } = await import('../../seo/config-store.js');
540
+ const staticCfg = getActuateConfig();
541
+ const overrides = await getSeoOverrides(db());
542
+ const effective = applySeoOverrides(staticCfg, overrides);
543
+ const collections = Object.entries(staticCfg?.collections ?? {})
544
+ .map(([slug, col]) => ({
545
+ slug,
546
+ label: col.labels?.plural ?? slug,
547
+ type: col.type ?? 'page',
548
+ urlPrefix: col.urlPrefix,
549
+ staticSeo: col.seo ?? null,
550
+ effectiveSeo: effective?.collections?.[slug]?.seo ?? null,
551
+ }))
552
+ .sort((a, b) => a.label.localeCompare(b.label));
553
+ return json({
554
+ data: {
555
+ static: { site: staticCfg?.seo ?? null },
556
+ overrides: overrides ?? null,
557
+ effective: { site: effective?.seo ?? null },
558
+ collections,
559
+ },
560
+ });
561
+ }
562
+ catch (err) {
563
+ return internalError(err, 'seo/config GET');
564
+ }
565
+ });
566
+ router.put('/seo/config', async (request) => {
567
+ try {
568
+ const auth = await requireAuth(request);
569
+ if (auth.error)
570
+ return auth.error;
571
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
572
+ if (roleErr)
573
+ return roleErr;
574
+ const body = (await request.json().catch(() => null));
575
+ if (!body || typeof body !== 'object') {
576
+ return errorResponse('Request body must be an object', 400);
577
+ }
578
+ // Shallow shape validation — we don't want to mirror the entire type
579
+ // here (the config-store merger ignores unknown keys anyway), but we do
580
+ // want to reject obviously malformed payloads early so the UI gets a
581
+ // clear 400 instead of a silent no-op.
582
+ const site = body.site && typeof body.site === 'object' ? body.site : undefined;
583
+ const collections = body.collections && typeof body.collections === 'object' ? body.collections : undefined;
584
+ if (site === undefined && collections === undefined) {
585
+ return errorResponse('Body must include at least one of `site` or `collections`', 400);
586
+ }
587
+ // Validate the title template server-side so an unsupported token can't be
588
+ // persisted and then silently break public title rendering.
589
+ if (site && typeof site.titleTemplate === 'string') {
590
+ const { validateTitleTemplate } = await import('../../seo/title-templates.js');
591
+ const result = validateTitleTemplate(site.titleTemplate);
592
+ if (!result.valid) {
593
+ return errorResponse(result.errors.join(' '), 400);
594
+ }
595
+ }
596
+ const { putSeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
597
+ // Merge with whatever's currently stored so the UI can PATCH a single
598
+ // section without wiping the other — `PUT /seo/config { site: {...} }`
599
+ // shouldn't blow away per-collection settings.
600
+ const current = (await getSeoOverrides(db())) ?? {};
601
+ const next = {
602
+ site: site !== undefined ? site : current.site,
603
+ collections: collections !== undefined
604
+ ? { ...(current.collections ?? {}), ...collections }
605
+ : current.collections,
606
+ // Preserve Technical-tab module settings so editing site/collection
607
+ // defaults never silently wipes crawl/robots/sitemap configuration.
608
+ module: current.module,
609
+ };
610
+ const saved = await putSeoOverrides(db(), next, auth.session.userId);
611
+ // Audit so changes to public SEO surface are traceable. We log a
612
+ // field-level diff (previous → new) for the site defaults, but redact
613
+ // verification token VALUES — only record which providers changed, never
614
+ // the token itself, since those land in public <meta> tags and shouldn't
615
+ // be duplicated into the audit trail.
616
+ try {
617
+ const { resolveSeoEnvironment } = await import('../../seo/robots.js');
618
+ const prevSite = (current.site ?? {});
619
+ const nextSite = (site ?? prevSite);
620
+ const changedFields = [];
621
+ for (const key of new Set([...Object.keys(prevSite), ...Object.keys(nextSite)])) {
622
+ if (key === 'verification')
623
+ continue;
624
+ if (JSON.stringify(prevSite[key]) !== JSON.stringify(nextSite[key])) {
625
+ changedFields.push({
626
+ field: key,
627
+ from: prevSite[key] ?? null,
628
+ to: nextSite[key] ?? null,
629
+ });
630
+ }
631
+ }
632
+ const prevVer = (prevSite.verification ?? {});
633
+ const nextVer = (nextSite.verification ?? {});
634
+ const verificationChanged = Object.keys({ ...prevVer, ...nextVer }).filter((k) => JSON.stringify(prevVer[k]) !== JSON.stringify(nextVer[k]));
635
+ await logEvent({
636
+ event: 'update_seo_config',
637
+ userId: auth.session.userId,
638
+ details: {
639
+ changedFields,
640
+ verificationChanged,
641
+ collectionKeys: Object.keys(collections ?? {}),
642
+ environment: resolveSeoEnvironment(),
643
+ source: 'settings.seo',
644
+ },
645
+ });
646
+ }
647
+ catch {
648
+ // Audit failures must never block the write.
649
+ }
650
+ return json({ data: saved });
651
+ }
652
+ catch (err) {
653
+ return internalError(err, 'seo/config PUT');
654
+ }
655
+ });
656
+ // Effective, computed crawl/index status for the General Settings card.
657
+ // Reads the SAME effective SEO config the public renderer uses, so the badge
658
+ // shown to editors never drifts from what search engines actually see.
659
+ router.get('/seo/index-status', async (request) => {
660
+ try {
661
+ const auth = await requireAuth(request);
662
+ if (auth.error)
663
+ return auth.error;
664
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
665
+ if (roleErr)
666
+ return roleErr;
667
+ const { getSiteIndexStatus } = await import('../../seo/index-status.js');
668
+ const status = await getSiteIndexStatus(db());
669
+ return json({ data: status });
670
+ }
671
+ catch (err) {
672
+ return internalError(err, 'seo/index-status GET');
673
+ }
674
+ });
675
+ // ---------------------------------------------------------------------------
676
+ // Technical SEO — sitemap status, crawl settings, robots.txt editor.
677
+ // All backed by the `module` block of the SEO overrides document.
678
+ // ---------------------------------------------------------------------------
679
+ /** Persist a partial patch onto the SEO module settings, preserving the rest. */
680
+ async function patchSeoModule(patch, userId) {
681
+ const { getSeoOverrides, putSeoOverrides } = await import('../../seo/config-store.js');
682
+ const current = (await getSeoOverrides(db())) ?? {};
683
+ await putSeoOverrides(db(), {
684
+ site: current.site,
685
+ collections: current.collections,
686
+ module: { ...(current.module ?? {}), ...patch },
687
+ }, userId);
688
+ }
689
+ /** Build the default robots.txt body (mirrors the public /robots.txt route). */
690
+ function buildDefaultRobots(request, cfg) {
691
+ const base = siteUrlFromRequest(request, cfg);
692
+ const sitemapUrl = cfg?.seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
693
+ const lines = ['User-agent: *', 'Allow: /', 'Disallow: /admin', 'Disallow: /api/', ''];
694
+ if (sitemapUrl)
695
+ lines.push(`Sitemap: ${sitemapUrl}`, '');
696
+ return lines.join('\n');
697
+ }
698
+ router.get('/seo/sitemap/status', async (request) => {
699
+ try {
700
+ const auth = await requireAuth(request);
701
+ if (auth.error)
702
+ return auth.error;
703
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
704
+ if (roleErr)
705
+ return roleErr;
706
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
707
+ const cfg = await loadEffectiveConfig();
708
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
709
+ const base = siteUrlFromRequest(request, cfg);
710
+ const cols = sitemapEligibleCollections(cfg);
711
+ let pagesIncluded = 0;
712
+ let pagesExcluded = 0;
713
+ for (const col of cols) {
714
+ const count = await db().document.count({
715
+ where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
716
+ });
717
+ pagesIncluded += count;
718
+ }
719
+ // Best-effort: count published docs explicitly flagged noindex.
720
+ try {
721
+ pagesExcluded = await db().document.count({
722
+ where: {
723
+ deletedAt: null,
724
+ status: 'PUBLISHED',
725
+ data: { path: ['noindex'], equals: true },
726
+ },
727
+ });
728
+ }
729
+ catch {
730
+ pagesExcluded = 0;
731
+ }
732
+ const warnings = [];
733
+ if (!settings.sitemapEnabled)
734
+ warnings.push('Sitemap generation is disabled in crawl settings.');
735
+ if (pagesIncluded === 0)
736
+ warnings.push('No published pages are eligible for the sitemap.');
737
+ return json({
738
+ data: {
739
+ sitemapUrl: `${base}/api/cms/sitemap.xml`,
740
+ lastGeneratedAt: settings.sitemapLastGeneratedAt,
741
+ pagesIncluded,
742
+ pagesExcluded,
743
+ autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
744
+ errors: [],
745
+ warnings,
746
+ },
747
+ });
748
+ }
749
+ catch (err) {
750
+ return internalError(err, 'seo/sitemap/status');
751
+ }
752
+ });
753
+ router.post('/seo/sitemap/regenerate', async (request) => {
754
+ try {
755
+ const auth = await requireAuth(request);
756
+ if (auth.error)
757
+ return auth.error;
758
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
759
+ if (roleErr)
760
+ return roleErr;
761
+ const now = new Date().toISOString();
762
+ await patchSeoModule({ sitemapLastGeneratedAt: now }, auth.session.userId);
763
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
764
+ const cfg = await loadEffectiveConfig();
765
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
766
+ const base = siteUrlFromRequest(request, cfg);
767
+ const cols = sitemapEligibleCollections(cfg);
768
+ let pagesIncluded = 0;
769
+ for (const col of cols) {
770
+ pagesIncluded += await db().document.count({
771
+ where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
772
+ });
773
+ }
774
+ return json({
775
+ data: {
776
+ sitemapUrl: `${base}/api/cms/sitemap.xml`,
777
+ lastGeneratedAt: now,
778
+ pagesIncluded,
779
+ pagesExcluded: 0,
780
+ autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
781
+ errors: [],
782
+ warnings: [],
783
+ },
784
+ });
785
+ }
786
+ catch (err) {
787
+ return internalError(err, 'seo/sitemap/regenerate');
788
+ }
789
+ });
790
+ router.get('/seo/crawl-settings', async (request) => {
791
+ try {
792
+ const auth = await requireAuth(request);
793
+ if (auth.error)
794
+ return auth.error;
795
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
796
+ if (roleErr)
797
+ return roleErr;
798
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
799
+ const s = resolveSeoModuleSettings(await getSeoOverrides(db()));
800
+ return json({
801
+ data: {
802
+ indexEntireSite: s.indexEntireSite,
803
+ includeImagesInSitemap: s.includeImagesInSitemap,
804
+ includePostsInSitemap: s.includePostsInSitemap,
805
+ noindexPaginatedArchives: s.noindexPaginatedArchives,
806
+ noindexTagPages: s.noindexTagPages,
807
+ sitemapEnabled: s.sitemapEnabled,
808
+ autoRegenerateSitemapOnPublish: s.autoRegenerateSitemapOnPublish,
809
+ pingSearchEnginesOnPublish: s.pingSearchEnginesOnPublish,
810
+ },
811
+ });
812
+ }
813
+ catch (err) {
814
+ return internalError(err, 'seo/crawl-settings GET');
815
+ }
816
+ });
817
+ router.put('/seo/crawl-settings', async (request) => {
818
+ try {
819
+ const auth = await requireAuth(request);
820
+ if (auth.error)
821
+ return auth.error;
822
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
823
+ if (roleErr)
824
+ return roleErr;
825
+ const body = (await request.json().catch(() => null));
826
+ if (!body || typeof body !== 'object') {
827
+ return errorResponse('Request body must be an object', 400);
828
+ }
829
+ const allowed = [
830
+ 'indexEntireSite',
831
+ 'includeImagesInSitemap',
832
+ 'includePostsInSitemap',
833
+ 'noindexPaginatedArchives',
834
+ 'noindexTagPages',
835
+ 'sitemapEnabled',
836
+ 'autoRegenerateSitemapOnPublish',
837
+ 'pingSearchEnginesOnPublish',
838
+ ];
839
+ const patch = {};
840
+ for (const key of allowed) {
841
+ if (typeof body[key] === 'boolean')
842
+ patch[key] = body[key];
843
+ }
844
+ if (Object.keys(patch).length === 0) {
845
+ return errorResponse('No valid crawl settings provided', 400);
846
+ }
847
+ // Snapshot current values so the audit trail records prev → new.
848
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
849
+ const { resolveSeoEnvironment } = await import('../../seo/robots.js');
850
+ const before = resolveSeoModuleSettings(await getSeoOverrides(db()));
851
+ await patchSeoModule(patch, auth.session.userId);
852
+ try {
853
+ const changedFields = Object.keys(patch).map((key) => ({
854
+ field: key,
855
+ from: before[key] ?? null,
856
+ to: patch[key],
857
+ }));
858
+ await logEvent({
859
+ event: 'update_seo_config',
860
+ userId: auth.session.userId,
861
+ details: {
862
+ section: 'crawl-settings',
863
+ changedFields,
864
+ environment: resolveSeoEnvironment(),
865
+ source: 'settings.seo',
866
+ },
867
+ });
868
+ }
869
+ catch {
870
+ /* audit best-effort */
871
+ }
872
+ return json({ data: { ok: true } });
873
+ }
874
+ catch (err) {
875
+ return internalError(err, 'seo/crawl-settings PUT');
876
+ }
877
+ });
878
+ router.get('/seo/robots', async (request) => {
879
+ try {
880
+ const auth = await requireAuth(request);
881
+ if (auth.error)
882
+ return auth.error;
883
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
884
+ if (roleErr)
885
+ return roleErr;
886
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
887
+ const cfg = await loadEffectiveConfig();
888
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
889
+ const custom = settings.robotsTxt?.trim();
890
+ const body = custom ? settings.robotsTxt : buildDefaultRobots(request, cfg);
891
+ return json({ data: { body, generated: !custom } });
892
+ }
893
+ catch (err) {
894
+ return internalError(err, 'seo/robots GET');
895
+ }
896
+ });
897
+ router.put('/seo/robots', async (request) => {
898
+ try {
899
+ const auth = await requireAuth(request);
900
+ if (auth.error)
901
+ return auth.error;
902
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
903
+ if (roleErr)
904
+ return roleErr;
905
+ const body = (await request.json().catch(() => null));
906
+ if (!body || typeof body.body !== 'string') {
907
+ return errorResponse('`body` (string) is required', 400);
908
+ }
909
+ if (body.body.length > 50_000) {
910
+ return errorResponse('robots.txt is too large (max 50KB)', 400);
911
+ }
912
+ await patchSeoModule({ robotsTxt: body.body }, auth.session.userId);
913
+ try {
914
+ await logEvent({
915
+ event: 'update_seo_config',
916
+ userId: auth.session.userId,
917
+ details: { section: 'robots.txt' },
918
+ });
919
+ }
920
+ catch {
921
+ /* audit best-effort */
922
+ }
923
+ return json({ data: { ok: true } });
924
+ }
925
+ catch (err) {
926
+ return internalError(err, 'seo/robots PUT');
927
+ }
928
+ });
929
+ }
930
+ //# sourceMappingURL=seo-public.js.map