@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,1036 @@
1
+ import { listDocuments, getDocument, createDocument, updateDocument } from '../../actions.js';
2
+ import { logEvent } from '../../security/audit.js';
3
+ import { validateTree } from '../../page-builder/validate.js';
4
+ import { auditAccessibility, fixAccessibility } from '../../page-builder/a11y-fix.js';
5
+ import { isResolvedIp } from '../../security/client-ip.js';
6
+ import { redactSecrets } from '../../security/redact.js';
7
+ import { getActuateConfig } from '../../config/runtime.js';
8
+ import { guardedDb } from '../guarded-db.js';
9
+ import { importAIPlugin, buildCoauthorPrompt, json, errorResponse, internalError, mediaUrl, deriveMediaTitleFromAlt, hasModel, modelNotAvailable, isAllowedStorageUrl, fetchImageForVision, requireAuth, requireCollectionScope, requireMediaScope, requirePageBuilderScope, buildActionContext, ADMIN_ROLES, requireRole, aiGenerateLimiter, aiAnalysisLimiter, AI_ANALYSIS_MAX_CHARS, checkRateLimitAsync, clientIp, } from '../route-helpers.js';
10
+ export function registerSavedSectionRoutes(router) {
11
+ const db = guardedDb;
12
+ // ---------------------------------------------------------------------------
13
+ // Saved Sections
14
+ // ---------------------------------------------------------------------------
15
+ router.get('/saved-sections', async (request) => {
16
+ try {
17
+ const auth = await requireAuth(request);
18
+ if (auth.error)
19
+ return auth.error;
20
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
21
+ if (roleErr)
22
+ return roleErr;
23
+ const d = db();
24
+ if (!hasModel(d, 'savedSection'))
25
+ return modelNotAvailable('SavedSection');
26
+ const url = new URL(request.url, 'http://localhost');
27
+ const category = url.searchParams.get('category');
28
+ const where = {};
29
+ if (category)
30
+ where.category = category;
31
+ const sections = await d.savedSection.findMany({
32
+ where,
33
+ orderBy: { updatedAt: 'desc' },
34
+ });
35
+ return json({ data: sections });
36
+ }
37
+ catch (err) {
38
+ return internalError(err, 'saved-sections list');
39
+ }
40
+ });
41
+ router.get('/saved-sections/:id', async (request, params) => {
42
+ try {
43
+ const auth = await requireAuth(request);
44
+ if (auth.error)
45
+ return auth.error;
46
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
47
+ if (roleErr)
48
+ return roleErr;
49
+ const d = db();
50
+ if (!hasModel(d, 'savedSection'))
51
+ return modelNotAvailable('SavedSection');
52
+ const section = await d.savedSection.findUnique({ where: { id: params.id } });
53
+ if (!section)
54
+ return errorResponse('Saved section not found', 404);
55
+ return json({ data: section });
56
+ }
57
+ catch (err) {
58
+ return internalError(err, 'saved-sections get');
59
+ }
60
+ });
61
+ router.post('/saved-sections', async (request) => {
62
+ try {
63
+ const auth = await requireAuth(request);
64
+ if (auth.error)
65
+ return auth.error;
66
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
67
+ if (roleErr)
68
+ return roleErr;
69
+ const d = db();
70
+ if (!hasModel(d, 'savedSection'))
71
+ return modelNotAvailable('SavedSection');
72
+ const body = (await request.json());
73
+ if (!body.name)
74
+ return errorResponse('name is required', 400);
75
+ if (!body.tree)
76
+ return errorResponse('tree is required', 400);
77
+ const wrappedTree = { id: '__validation_wrapper__', type: 'page', children: [body.tree] };
78
+ const validation = validateTree(wrappedTree);
79
+ if (!validation.valid) {
80
+ return errorResponse(`Invalid section tree: ${validation.errors.join('; ')}`, 400);
81
+ }
82
+ const validCategories = ['header', 'footer', 'content', 'sidebar'];
83
+ const category = body.category || 'content';
84
+ if (!validCategories.includes(category)) {
85
+ return errorResponse('category must be header, footer, content, or sidebar', 400);
86
+ }
87
+ const section = await d.savedSection.create({
88
+ data: {
89
+ name: body.name,
90
+ description: body.description || null,
91
+ category,
92
+ tree: body.tree,
93
+ thumbnail: body.thumbnail || null,
94
+ },
95
+ });
96
+ await logEvent({
97
+ event: 'settings_changed',
98
+ userId: auth.session.userId,
99
+ details: { action: 'saved_section_created', sectionId: section.id, name: section.name },
100
+ });
101
+ return json({ data: section }, 201);
102
+ }
103
+ catch (err) {
104
+ return internalError(err, 'saved-sections create');
105
+ }
106
+ });
107
+ router.put('/saved-sections/:id', async (request, params) => {
108
+ try {
109
+ const auth = await requireAuth(request);
110
+ if (auth.error)
111
+ return auth.error;
112
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
113
+ if (roleErr)
114
+ return roleErr;
115
+ const d = db();
116
+ if (!hasModel(d, 'savedSection'))
117
+ return modelNotAvailable('SavedSection');
118
+ const existing = await d.savedSection.findUnique({ where: { id: params.id } });
119
+ if (!existing)
120
+ return errorResponse('Saved section not found', 404);
121
+ const body = (await request.json());
122
+ const update = {};
123
+ if (body.name !== undefined)
124
+ update.name = body.name;
125
+ if (body.description !== undefined)
126
+ update.description = body.description;
127
+ if (body.thumbnail !== undefined)
128
+ update.thumbnail = body.thumbnail;
129
+ if (body.category !== undefined) {
130
+ const validCategories = ['header', 'footer', 'content', 'sidebar'];
131
+ if (!validCategories.includes(body.category)) {
132
+ return errorResponse('category must be header, footer, content, or sidebar', 400);
133
+ }
134
+ update.category = body.category;
135
+ }
136
+ if (body.tree !== undefined) {
137
+ const wrappedTree = { id: '__validation_wrapper__', type: 'page', children: [body.tree] };
138
+ const validation = validateTree(wrappedTree);
139
+ if (!validation.valid) {
140
+ return errorResponse(`Invalid section tree: ${validation.errors.join('; ')}`, 400);
141
+ }
142
+ update.tree = body.tree;
143
+ }
144
+ const section = await d.savedSection.update({
145
+ where: { id: params.id },
146
+ data: update,
147
+ });
148
+ await logEvent({
149
+ event: 'settings_changed',
150
+ userId: auth.session.userId,
151
+ details: { action: 'saved_section_updated', sectionId: section.id, name: section.name },
152
+ });
153
+ return json({ data: section });
154
+ }
155
+ catch (err) {
156
+ return internalError(err, 'saved-sections update');
157
+ }
158
+ });
159
+ router.delete('/saved-sections/:id', async (request, params) => {
160
+ try {
161
+ const auth = await requireAuth(request);
162
+ if (auth.error)
163
+ return auth.error;
164
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
165
+ if (roleErr)
166
+ return roleErr;
167
+ const d = db();
168
+ if (!hasModel(d, 'savedSection'))
169
+ return modelNotAvailable('SavedSection');
170
+ const existing = await d.savedSection.findUnique({ where: { id: params.id } });
171
+ if (!existing)
172
+ return errorResponse('Saved section not found', 404);
173
+ await d.savedSection.delete({ where: { id: params.id } });
174
+ await logEvent({
175
+ event: 'settings_changed',
176
+ userId: auth.session.userId,
177
+ details: { action: 'saved_section_deleted', sectionId: existing.id, name: existing.name },
178
+ });
179
+ return json({ data: { deleted: true } });
180
+ }
181
+ catch (err) {
182
+ return internalError(err, 'saved-sections delete');
183
+ }
184
+ });
185
+ // ─── Page Builder AI Generation ─────────────────────────────────────
186
+ // Hard caps for AI input to keep token cost bounded and to limit the
187
+ // surface area for prompt injection. Adjust via the `ai.limits` block in
188
+ // the CMS config if you want stricter values; never raise past 8k.
189
+ const AI_PROMPT_MAX_CHARS = 4000;
190
+ const AI_CONTEXT_MAX_CHARS = 8000;
191
+ router.post('/page-builder/generate', async (request) => {
192
+ try {
193
+ const auth = await requireAuth(request);
194
+ if (auth.error)
195
+ return auth.error;
196
+ const scopeErr = requirePageBuilderScope(auth.session);
197
+ if (scopeErr)
198
+ return scopeErr;
199
+ // Per-user rate limit. AI generation is the single most expensive
200
+ // operation in the CMS — without this, a compromised admin account
201
+ // can drain a provider key in minutes.
202
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
203
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
204
+ }
205
+ const body = await request.json();
206
+ const { prompt, template, context, steps, tone } = body;
207
+ if (!prompt || typeof prompt !== 'string') {
208
+ return errorResponse('prompt is required', 400);
209
+ }
210
+ if (prompt.length > AI_PROMPT_MAX_CHARS) {
211
+ return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
212
+ }
213
+ if (typeof context === 'string' && context.length > AI_CONTEXT_MAX_CHARS) {
214
+ return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
215
+ }
216
+ if (!steps || !Array.isArray(steps) || steps.length === 0) {
217
+ return errorResponse('steps array is required', 400);
218
+ }
219
+ const validSteps = ['structure', 'content', 'seo', 'accessibility'];
220
+ for (const s of steps) {
221
+ if (!validSteps.includes(s)) {
222
+ return errorResponse(`Invalid step: ${s}. Valid steps: ${validSteps.join(', ')}`, 400);
223
+ }
224
+ }
225
+ await logEvent({
226
+ event: 'settings_changed',
227
+ userId: auth.session.userId,
228
+ details: {
229
+ action: 'page_generation_started',
230
+ // Redact secrets from the prompt before persisting to the audit log.
231
+ // Even an admin pasting a key into a prompt by mistake shouldn't
232
+ // result in that key being mirrored into permanent storage.
233
+ prompt: redactSecrets(prompt).slice(0, 500),
234
+ steps,
235
+ template,
236
+ },
237
+ });
238
+ let generatePage = null;
239
+ try {
240
+ const aiModule = await importAIPlugin();
241
+ generatePage = aiModule.generatePage;
242
+ }
243
+ catch {
244
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use page generation.', 501);
245
+ }
246
+ const result = await generatePage({ prompt, template, context, steps, tone });
247
+ await logEvent({
248
+ event: 'settings_changed',
249
+ userId: auth.session.userId,
250
+ details: {
251
+ action: 'page_generation_completed',
252
+ totalTokensUsed: result.totalTokensUsed,
253
+ totalDurationMs: result.totalDurationMs,
254
+ stepsCompleted: result.steps.map((s) => s.step),
255
+ },
256
+ });
257
+ return json({ data: result });
258
+ }
259
+ catch (err) {
260
+ return internalError(err, 'page-builder generate');
261
+ }
262
+ });
263
+ router.post('/page-builder/generate-block', async (request) => {
264
+ try {
265
+ const auth = await requireAuth(request);
266
+ if (auth.error)
267
+ return auth.error;
268
+ const scopeErr = requirePageBuilderScope(auth.session);
269
+ if (scopeErr)
270
+ return scopeErr;
271
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
272
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
273
+ }
274
+ const body = await request.json();
275
+ const { blockType, variant, pageContext, tone } = body;
276
+ if (!blockType || typeof blockType !== 'string') {
277
+ return errorResponse('blockType is required', 400);
278
+ }
279
+ // Limit caller-supplied context that flows directly into the prompt.
280
+ if (pageContext) {
281
+ const total = JSON.stringify(pageContext).length;
282
+ if (total > AI_CONTEXT_MAX_CHARS) {
283
+ return errorResponse(`pageContext exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
284
+ }
285
+ }
286
+ let generateBlockContent = null;
287
+ try {
288
+ const aiModule = await importAIPlugin();
289
+ generateBlockContent = aiModule.generateBlockContent;
290
+ }
291
+ catch {
292
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use block generation.', 501);
293
+ }
294
+ const data = await generateBlockContent({ blockType, variant, pageContext, tone });
295
+ return json({ data });
296
+ }
297
+ catch (err) {
298
+ return internalError(err, 'page-builder generate-block');
299
+ }
300
+ });
301
+ /**
302
+ * One-shot page creation: run the AI page generator and persist the result
303
+ * as a new document in a single call. Designed for AI agents that want to
304
+ * create a complete page from a prompt without orchestrating two requests
305
+ * (generate, then create). Defaults to status=DRAFT so the human reviewer
306
+ * can polish before publishing.
307
+ */
308
+ router.post('/page-builder/create', async (request) => {
309
+ try {
310
+ const auth = await requireAuth(request);
311
+ if (auth.error)
312
+ return auth.error;
313
+ const scopeErr = requirePageBuilderScope(auth.session);
314
+ if (scopeErr)
315
+ return scopeErr;
316
+ // The create path also writes to a collection, so the API key must hold
317
+ // create scope on the destination collection (defaults to 'pages').
318
+ const body = (await request.json());
319
+ const targetCollection = body.collection ?? 'pages';
320
+ const collectionScopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
321
+ if (collectionScopeErr)
322
+ return collectionScopeErr;
323
+ if (!body.prompt || typeof body.prompt !== 'string') {
324
+ return errorResponse('prompt is required', 400);
325
+ }
326
+ if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
327
+ return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
328
+ }
329
+ // Same rate-limit bucket as /generate — one create == one expensive LLM
330
+ // run, so it should count against the same hourly cap.
331
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
332
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
333
+ }
334
+ const steps = Array.isArray(body.steps) && body.steps.length > 0
335
+ ? body.steps
336
+ : ['structure', 'content', 'seo', 'accessibility'];
337
+ const validSteps = ['structure', 'content', 'seo', 'accessibility'];
338
+ for (const s of steps) {
339
+ if (!validSteps.includes(s)) {
340
+ return errorResponse(`Invalid step: ${s}. Valid steps: ${validSteps.join(', ')}`, 400);
341
+ }
342
+ }
343
+ let generatePage = null;
344
+ try {
345
+ const aiModule = await importAIPlugin();
346
+ generatePage = aiModule.generatePage;
347
+ }
348
+ catch {
349
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use page generation.', 501);
350
+ }
351
+ const result = await generatePage({
352
+ prompt: body.prompt,
353
+ template: body.template,
354
+ context: body.context,
355
+ steps,
356
+ tone: body.tone,
357
+ });
358
+ const tree = result?.tree;
359
+ if (!tree) {
360
+ return errorResponse('Page generation returned no tree', 502);
361
+ }
362
+ // Pull SEO metadata out of the generator output so we can store it on
363
+ // the document alongside the layout tree.
364
+ const seoStep = (result?.steps ?? []).find((s) => s.step === 'seo');
365
+ const meta = seoStep?.data ?? {};
366
+ const title = body.title ?? meta.title ?? meta.metaTitle ?? 'Untitled';
367
+ const slug = body.slug ??
368
+ title
369
+ .toLowerCase()
370
+ .replace(/[^a-z0-9]+/g, '-')
371
+ .replace(/^-|-$/g, '');
372
+ // Determine final status — explicit body.status wins, then publish: true,
373
+ // then DRAFT.
374
+ const status = body.status === 'PUBLISHED' || body.publish === true ? 'PUBLISHED' : 'DRAFT';
375
+ const docPayload = {
376
+ title,
377
+ slug,
378
+ status,
379
+ layout: tree,
380
+ pageSettings: {
381
+ metaTitle: meta.metaTitle ?? meta.title ?? title,
382
+ metaDescription: meta.metaDescription ?? meta.description ?? '',
383
+ ...(meta.canonical ? { canonical: meta.canonical } : {}),
384
+ ...(meta.schemaType ? { schemaType: meta.schemaType } : {}),
385
+ },
386
+ };
387
+ const ctx = buildActionContext(auth.session, db());
388
+ const doc = await createDocument(targetCollection, docPayload, ctx);
389
+ await logEvent({
390
+ event: 'settings_changed',
391
+ userId: auth.session.userId,
392
+ details: {
393
+ action: 'page_create_from_prompt',
394
+ collection: targetCollection,
395
+ documentId: doc?.id,
396
+ prompt: redactSecrets(body.prompt).slice(0, 500),
397
+ totalTokensUsed: result.totalTokensUsed,
398
+ totalDurationMs: result.totalDurationMs,
399
+ },
400
+ });
401
+ return json({
402
+ data: {
403
+ document: doc,
404
+ generation: {
405
+ steps: result.steps,
406
+ totalTokensUsed: result.totalTokensUsed,
407
+ totalDurationMs: result.totalDurationMs,
408
+ },
409
+ },
410
+ }, 201);
411
+ }
412
+ catch (err) {
413
+ return internalError(err, 'page-builder create');
414
+ }
415
+ });
416
+ /**
417
+ * Generic AI content authoring for ANY collection.
418
+ *
419
+ * `POST /collections/:slug/ai-create` is the non-page counterpart to
420
+ * `/page-builder/create`. It takes a free-form prompt, looks up the
421
+ * collection's field schema from the running config, asks the AI provider
422
+ * to produce structured JSON matching that schema, validates / coerces the
423
+ * output, and persists the result as a draft (or published) document.
424
+ *
425
+ * Used by:
426
+ * - the admin "Generate from prompt" affordance on the collection list
427
+ * - the MCP server's `create_in_collection` / `create_blog_post` / etc.
428
+ * - AI agents calling the REST API directly
429
+ */
430
+ router.post('/collections/:slug/ai-create', async (request, params) => {
431
+ try {
432
+ const auth = await requireAuth(request);
433
+ if (auth.error)
434
+ return auth.error;
435
+ const targetCollection = params.slug;
436
+ const scopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
437
+ if (scopeErr)
438
+ return scopeErr;
439
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
440
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
441
+ }
442
+ const body = (await request.json());
443
+ if (!body.prompt || typeof body.prompt !== 'string') {
444
+ return errorResponse('prompt is required', 400);
445
+ }
446
+ if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
447
+ return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
448
+ }
449
+ if (body.context && body.context.length > AI_CONTEXT_MAX_CHARS) {
450
+ return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
451
+ }
452
+ const cfg = getActuateConfig();
453
+ const collection = cfg?.collections?.[targetCollection];
454
+ if (!collection) {
455
+ return errorResponse(`Collection "${targetCollection}" not found`, 404);
456
+ }
457
+ // Refuse page-builder-driven collections — those have a layout tree
458
+ // (not flat field data) and should use `/page-builder/create` instead.
459
+ const hasLayoutField = Object.values(collection.fields).some((f) => f.type === 'blocks');
460
+ if (hasLayoutField) {
461
+ return errorResponse(`Collection "${targetCollection}" uses the page builder. Use POST /api/cms/page-builder/create instead.`, 400);
462
+ }
463
+ let generateDocumentContent = null;
464
+ try {
465
+ const aiModule = await importAIPlugin();
466
+ generateDocumentContent = aiModule.generateDocumentContent;
467
+ }
468
+ catch {
469
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use content authoring.', 501);
470
+ }
471
+ if (!generateDocumentContent) {
472
+ return errorResponse('AI plugin missing `generateDocumentContent`. Upgrade @actuate-media/plugin-ai to >= 0.2.0.', 501);
473
+ }
474
+ const result = await generateDocumentContent({
475
+ prompt: body.prompt,
476
+ collection: {
477
+ slug: collection.slug,
478
+ labels: collection.labels,
479
+ type: collection.type,
480
+ fields: collection.fields,
481
+ seo: collection.seo,
482
+ },
483
+ context: { existingContent: body.context, targetAudience: body.targetAudience },
484
+ tone: body.tone,
485
+ ...(typeof body.maxTokens === 'number' ? { maxTokens: body.maxTokens } : {}),
486
+ ...(typeof body.temperature === 'number' ? { temperature: body.temperature } : {}),
487
+ });
488
+ const shouldPublish = body.status === 'PUBLISHED' || body.publish === true;
489
+ const finalTitle = body.title ?? result.title;
490
+ const finalSlug = body.slug ?? result.slug;
491
+ // Spread AI data FIRST so explicit caller overrides (title, slug) win.
492
+ // We don't use `pageSettings` here — that's a page-builder-specific
493
+ // wire shape. For non-page collections SEO copy lives on regular
494
+ // fields (metaTitle / metaDescription) declared via the seoFields
495
+ // preset, and the AI generator already populates those inside
496
+ // result.data when they exist on the schema.
497
+ const docPayload = {
498
+ ...result.data,
499
+ title: finalTitle,
500
+ slug: finalSlug,
501
+ };
502
+ const ctx = buildActionContext(auth.session, db());
503
+ let doc = await createDocument(targetCollection, docPayload, ctx);
504
+ // Optional publish: this path deliberately creates the document as a
505
+ // DRAFT first (no `status` is passed to createDocument), then runs a
506
+ // follow-up updateDocument to publish. Going through update — rather than
507
+ // publishing in the single create call — is what lets a caller with only
508
+ // `create` scope still land a draft and receive a partial-success warning
509
+ // (below) instead of a hard failure, which is the safest fallback.
510
+ if (shouldPublish && doc?.id) {
511
+ try {
512
+ doc = await updateDocument(targetCollection, doc.id, { status: 'PUBLISHED' }, ctx);
513
+ }
514
+ catch (publishErr) {
515
+ // Surface a 207-style partial success: the draft was created but
516
+ // publish was denied. We return 201 with a `warning` field so the
517
+ // agent knows the doc landed but needs human publishing.
518
+ return json({
519
+ data: {
520
+ document: doc,
521
+ generation: {
522
+ usage: result.usage,
523
+ durationMs: result.durationMs,
524
+ seo: result.seo,
525
+ },
526
+ },
527
+ warning: `Document created as DRAFT — publish failed: ${publishErr instanceof Error ? publishErr.message : 'unknown'}`,
528
+ }, 201);
529
+ }
530
+ }
531
+ await logEvent({
532
+ event: 'settings_changed',
533
+ userId: auth.session.userId,
534
+ details: {
535
+ action: 'collection_ai_create',
536
+ collection: targetCollection,
537
+ documentId: doc?.id,
538
+ prompt: redactSecrets(body.prompt).slice(0, 500),
539
+ tokensUsed: result.usage,
540
+ durationMs: result.durationMs,
541
+ },
542
+ });
543
+ return json({
544
+ data: {
545
+ document: doc,
546
+ generation: {
547
+ usage: result.usage,
548
+ durationMs: result.durationMs,
549
+ seo: result.seo,
550
+ },
551
+ },
552
+ }, 201);
553
+ }
554
+ catch (err) {
555
+ return internalError(err, 'collections/:slug/ai-create');
556
+ }
557
+ });
558
+ router.post('/page-builder/audit-a11y', async (request) => {
559
+ try {
560
+ const auth = await requireAuth(request);
561
+ if (auth.error)
562
+ return auth.error;
563
+ const scopeErr = requirePageBuilderScope(auth.session);
564
+ if (scopeErr)
565
+ return scopeErr;
566
+ const body = await request.json();
567
+ const tree = body.tree;
568
+ if (!tree || tree.type !== 'page') {
569
+ return errorResponse('A valid page tree is required', 400);
570
+ }
571
+ const issues = auditAccessibility(tree);
572
+ return json({ data: { issues } });
573
+ }
574
+ catch (err) {
575
+ return internalError(err, 'page-builder audit-a11y');
576
+ }
577
+ });
578
+ router.post('/page-builder/fix-a11y', async (request) => {
579
+ try {
580
+ const auth = await requireAuth(request);
581
+ if (auth.error)
582
+ return auth.error;
583
+ const scopeErr = requirePageBuilderScope(auth.session);
584
+ if (scopeErr)
585
+ return scopeErr;
586
+ const body = await request.json();
587
+ const tree = body.tree;
588
+ if (!tree || tree.type !== 'page') {
589
+ return errorResponse('A valid page tree is required', 400);
590
+ }
591
+ const result = fixAccessibility(tree);
592
+ await logEvent({
593
+ event: 'settings_changed',
594
+ userId: auth.session.userId,
595
+ details: {
596
+ action: 'a11y_auto_fix',
597
+ fixedCount: result.report.fixedCount,
598
+ remainingCount: result.report.remainingCount,
599
+ },
600
+ });
601
+ return json({ data: result });
602
+ }
603
+ catch (err) {
604
+ return internalError(err, 'page-builder fix-a11y');
605
+ }
606
+ });
607
+ /**
608
+ * Composite document audit — Slice F.
609
+ *
610
+ * Runs every scoring dimension plugin-ai exposes (SEO, readability,
611
+ * accessibility, freshness) plus structural sanity checks (missing
612
+ * title, missing meta description, alt-less images, thin body) and
613
+ * returns a single composite grade with per-dimension breakdowns.
614
+ *
615
+ * Two input modes:
616
+ * - `{ collection, id }` — load the document from the DB and audit
617
+ * its persisted content. Honors field-level access control.
618
+ * - `{ title, body, ... }` — audit ad-hoc content without persisting,
619
+ * used by inline AI co-author and live-preview drafts.
620
+ *
621
+ * Deterministic: makes no LLM calls, so it is cheap and safe to call
622
+ * on every save / debounced field edit. The expensive AI features
623
+ * (suggestions, rewrites) live in separate endpoints.
624
+ */
625
+ router.post('/ai/audit-document', async (request) => {
626
+ try {
627
+ const auth = await requireAuth(request);
628
+ if (auth.error)
629
+ return auth.error;
630
+ if (!(await checkRateLimitAsync(aiAnalysisLimiter, `ai-audit:${auth.session.userId}`))) {
631
+ return errorResponse('Document audit rate limit reached. Try again later.', 429);
632
+ }
633
+ const body = (await request.json());
634
+ if (typeof body.body === 'string' && body.body.length > AI_ANALYSIS_MAX_CHARS) {
635
+ return errorResponse(`body exceeds ${AI_ANALYSIS_MAX_CHARS} character limit`, 400);
636
+ }
637
+ let auditModule;
638
+ try {
639
+ auditModule = await importAIPlugin();
640
+ }
641
+ catch {
642
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use document audit.', 501);
643
+ }
644
+ if (typeof auditModule?.auditDocument !== 'function') {
645
+ return errorResponse('AI plugin missing `auditDocument`. Upgrade @actuate-media/plugin-ai to >= 0.3.0.', 501);
646
+ }
647
+ const auditIp = clientIp(request);
648
+ await logEvent({
649
+ event: 'ai_audit_document',
650
+ userId: auth.session.userId,
651
+ ipAddress: isResolvedIp(auditIp) ? auditIp : undefined,
652
+ details: body.collection && body.id
653
+ ? { collection: body.collection, id: body.id }
654
+ : { adhoc: true },
655
+ });
656
+ // Branch A — load from DB.
657
+ if (body.collection && body.id) {
658
+ const scopeErr = requireCollectionScope(auth.session, body.collection, 'read');
659
+ if (scopeErr)
660
+ return scopeErr;
661
+ const ctx = buildActionContext(auth.session, db());
662
+ const doc = (await getDocument(body.collection, body.id, ctx));
663
+ if (!doc)
664
+ return errorResponse('Document not found', 404);
665
+ const data = (doc.data ?? {});
666
+ const pageSettings = (doc.pageSettings ?? {});
667
+ const titleStr = String(body.title ?? doc.title ?? data.title ?? data.name ?? '');
668
+ const bodyStr = String(body.body ?? data.body ?? data.content ?? data.excerpt ?? data.description ?? '');
669
+ const metaTitle = String(body.metaTitle ?? pageSettings.metaTitle ?? data.metaTitle ?? titleStr);
670
+ const metaDescription = String(body.metaDescription ??
671
+ pageSettings.metaDescription ??
672
+ data.metaDescription ??
673
+ data.excerpt ??
674
+ '');
675
+ const targetKeyword = body.targetKeyword
676
+ ? body.targetKeyword
677
+ : typeof pageSettings.targetKeyword === 'string'
678
+ ? pageSettings.targetKeyword
679
+ : typeof data.targetKeyword === 'string'
680
+ ? data.targetKeyword
681
+ : undefined;
682
+ const result = auditModule.auditDocument({
683
+ title: titleStr,
684
+ body: bodyStr,
685
+ metaTitle,
686
+ metaDescription,
687
+ ...(targetKeyword ? { targetKeyword } : {}),
688
+ ...(body.url ? { url: body.url } : {}),
689
+ ...(body.headings ? { headings: body.headings } : {}),
690
+ ...(body.images ? { images: body.images } : {}),
691
+ ...(body.internalLinks ? { internalLinks: body.internalLinks } : {}),
692
+ ...(body.externalLinks ? { externalLinks: body.externalLinks } : {}),
693
+ ...(doc.publishedAt ? { publishedAt: doc.publishedAt } : {}),
694
+ ...(doc.updatedAt ? { updatedAt: doc.updatedAt } : {}),
695
+ ...(body.contentType ? { contentType: body.contentType } : {}),
696
+ });
697
+ return json({ data: result });
698
+ }
699
+ // Branch B — ad-hoc audit.
700
+ if (!body.title && !body.body) {
701
+ return errorResponse('Provide either { collection, id } to audit a stored document or at least { title } / { body } for an ad-hoc audit.', 400);
702
+ }
703
+ const result = auditModule.auditDocument({
704
+ title: body.title ?? '',
705
+ body: body.body ?? '',
706
+ ...(body.metaTitle ? { metaTitle: body.metaTitle } : {}),
707
+ ...(body.metaDescription ? { metaDescription: body.metaDescription } : {}),
708
+ ...(body.targetKeyword ? { targetKeyword: body.targetKeyword } : {}),
709
+ ...(body.url ? { url: body.url } : {}),
710
+ ...(body.headings ? { headings: body.headings } : {}),
711
+ ...(body.images ? { images: body.images } : {}),
712
+ ...(body.internalLinks ? { internalLinks: body.internalLinks } : {}),
713
+ ...(body.externalLinks ? { externalLinks: body.externalLinks } : {}),
714
+ ...(body.publishedAt ? { publishedAt: body.publishedAt } : {}),
715
+ ...(body.updatedAt ? { updatedAt: body.updatedAt } : {}),
716
+ ...(body.contentType ? { contentType: body.contentType } : {}),
717
+ });
718
+ return json({ data: result });
719
+ }
720
+ catch (err) {
721
+ return internalError(err, 'ai audit-document');
722
+ }
723
+ });
724
+ /**
725
+ * Internal link suggestions — Slice F.
726
+ *
727
+ * Given a body of content, scan published CMS documents for phrases the
728
+ * author could link to (by title or declared keywords) and return a
729
+ * ranked list of suggestions. The matching is deterministic (no LLM call)
730
+ * but expensive enough that we cap the candidate set and the response
731
+ * size to keep p99 under control.
732
+ *
733
+ * Optionally constrain candidates to a single collection (e.g. only
734
+ * suggest links to `services` while editing a blog post).
735
+ */
736
+ /**
737
+ * Inline AI co-author — text-transformation endpoints (Phase 2).
738
+ *
739
+ * Four micro-actions that operate on a chunk of text supplied by the
740
+ * caller: rewrite, expand, compress, proofread. They wrap the existing
741
+ * plugin-ai writing primitives so the admin and MCP clients have a
742
+ * stable HTTP surface.
743
+ *
744
+ * Every action is rate-limited under the same bucket as content
745
+ * generation (20 calls / hour / user) because each one hits the
746
+ * configured LLM provider.
747
+ */
748
+ router.post('/ai/coauthor', async (request) => {
749
+ try {
750
+ const auth = await requireAuth(request);
751
+ if (auth.error)
752
+ return auth.error;
753
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-coauthor:${auth.session.userId}`))) {
754
+ return errorResponse('AI co-author rate limit reached. Try again in an hour.', 429);
755
+ }
756
+ const body = (await request.json());
757
+ if (!body.text || typeof body.text !== 'string') {
758
+ return errorResponse('text is required', 400);
759
+ }
760
+ if (body.text.length > AI_CONTEXT_MAX_CHARS) {
761
+ return errorResponse(`text exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
762
+ }
763
+ const action = body.action ?? 'rewrite';
764
+ if (!['rewrite', 'expand', 'compress', 'proofread'].includes(action)) {
765
+ return errorResponse(`Unknown action "${action}"`, 400);
766
+ }
767
+ let result;
768
+ // Governed path first: route through the connection + model configured in
769
+ // Settings > AI, enforcing the feature toggle/budget and recording usage.
770
+ const { generateTextForFeature } = await import('../../ai/index.js');
771
+ const { system, user, maxTokens } = buildCoauthorPrompt(action, body);
772
+ const governed = await generateTextForFeature(db(), 'ai.features.contentWriting', {
773
+ prompt: user,
774
+ system,
775
+ maxTokens,
776
+ userId: auth.session.userId,
777
+ useBrandVoice: true,
778
+ });
779
+ if (governed.ok) {
780
+ result = { text: governed.text, ...(action === 'proofread' ? { changes: [] } : {}) };
781
+ }
782
+ else if (governed.code === 'feature_disabled') {
783
+ return errorResponse(governed.message, 403);
784
+ }
785
+ else if (governed.code === 'budget_exceeded') {
786
+ return errorResponse(governed.message, 429);
787
+ }
788
+ else if (governed.code === 'provider_error') {
789
+ return errorResponse(`AI provider error: ${governed.message}`, 502);
790
+ }
791
+ else {
792
+ // No governed provider/model/key yet → fall back to the legacy global
793
+ // plugin-ai provider so existing installs keep working.
794
+ let aiModule;
795
+ try {
796
+ aiModule = await importAIPlugin();
797
+ }
798
+ catch {
799
+ return errorResponse('AI co-author is not configured. Connect a provider in Settings > AI ' +
800
+ 'or install @actuate-media/plugin-ai.', 501);
801
+ }
802
+ try {
803
+ if (action === 'rewrite') {
804
+ if (typeof aiModule.rewrite !== 'function') {
805
+ return errorResponse('AI plugin missing `rewrite`.', 501);
806
+ }
807
+ const text = (await aiModule.rewrite(body.text, body.style, body.tone));
808
+ result = { text };
809
+ }
810
+ else if (action === 'expand') {
811
+ if (typeof aiModule.expand !== 'function') {
812
+ return errorResponse('AI plugin missing `expand`.', 501);
813
+ }
814
+ const text = (await aiModule.expand(body.text, body.instructions));
815
+ result = { text };
816
+ }
817
+ else if (action === 'compress') {
818
+ if (typeof aiModule.compress !== 'function') {
819
+ return errorResponse('AI plugin missing `compress`.', 501);
820
+ }
821
+ const text = (await aiModule.compress(body.text, body.targetLength));
822
+ result = { text };
823
+ }
824
+ else {
825
+ if (typeof aiModule.proofread !== 'function') {
826
+ return errorResponse('AI plugin missing `proofread`.', 501);
827
+ }
828
+ const r = (await aiModule.proofread(body.text));
829
+ result = { text: r.corrected, changes: r.changes };
830
+ }
831
+ }
832
+ catch (err) {
833
+ // Surface provider errors (missing API key, rate-limited upstream) with
834
+ // a meaningful status code instead of 500.
835
+ const msg = err instanceof Error ? err.message : 'unknown';
836
+ if (msg.toLowerCase().includes('not configured') ||
837
+ msg.toLowerCase().includes('missing')) {
838
+ return errorResponse(`AI provider is not configured: ${msg}`, 501);
839
+ }
840
+ throw err;
841
+ }
842
+ }
843
+ await logEvent({
844
+ event: 'settings_changed',
845
+ userId: auth.session.userId,
846
+ details: {
847
+ action: `ai_coauthor_${action}`,
848
+ inputChars: body.text.length,
849
+ outputChars: result.text.length,
850
+ },
851
+ });
852
+ return json({ data: { action, ...result } });
853
+ }
854
+ catch (err) {
855
+ return internalError(err, 'ai coauthor');
856
+ }
857
+ });
858
+ router.post('/ai/suggest-internal-links', async (request) => {
859
+ try {
860
+ const auth = await requireAuth(request);
861
+ if (auth.error)
862
+ return auth.error;
863
+ if (!(await checkRateLimitAsync(aiAnalysisLimiter, `ai-links:${auth.session.userId}`))) {
864
+ return errorResponse('Internal-link suggestion rate limit reached. Try again later.', 429);
865
+ }
866
+ const body = (await request.json());
867
+ if (!body.content || typeof body.content !== 'string') {
868
+ return errorResponse('content is required', 400);
869
+ }
870
+ if (body.content.length > AI_ANALYSIS_MAX_CHARS) {
871
+ return errorResponse(`content exceeds ${AI_ANALYSIS_MAX_CHARS} character limit`, 400);
872
+ }
873
+ const limit = Math.max(1, Math.min(50, typeof body.limit === 'number' ? body.limit : 10));
874
+ let aiModule;
875
+ try {
876
+ aiModule = await importAIPlugin();
877
+ }
878
+ catch {
879
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use internal link suggestions.', 501);
880
+ }
881
+ if (typeof aiModule?.suggestInternalLinks !== 'function') {
882
+ return errorResponse('AI plugin missing `suggestInternalLinks`. Upgrade @actuate-media/plugin-ai to >= 0.3.0.', 501);
883
+ }
884
+ const linksIp = clientIp(request);
885
+ await logEvent({
886
+ event: 'ai_suggest_internal_links',
887
+ userId: auth.session.userId,
888
+ ipAddress: isResolvedIp(linksIp) ? linksIp : undefined,
889
+ details: { ...(body.collection ? { collection: body.collection } : {}), limit },
890
+ });
891
+ const ctx = buildActionContext(auth.session, db());
892
+ // Pull up to 200 published candidates from the requested collection
893
+ // (or every readable collection when none is specified). We rely on
894
+ // listDocuments' built-in access control so the agent can't surface
895
+ // links to documents it isn't allowed to read.
896
+ const cfg = getActuateConfig();
897
+ const collectionSlugs = body.collection
898
+ ? [body.collection]
899
+ : Object.keys(cfg?.collections ?? {});
900
+ const pages = [];
901
+ for (const slug of collectionSlugs) {
902
+ const scopeErr = requireCollectionScope(auth.session, slug, 'read');
903
+ if (scopeErr)
904
+ continue;
905
+ const listed = await listDocuments({ collection: slug, status: 'PUBLISHED', pageSize: 200 }, ctx);
906
+ for (const doc of listed.docs) {
907
+ if (body.excludeId && doc.id === body.excludeId)
908
+ continue;
909
+ const data = (doc.data ?? {});
910
+ const titleStr = String(doc.title ?? data.title ?? data.name ?? '');
911
+ if (!titleStr.trim())
912
+ continue;
913
+ const slugStr = String(doc.slug ?? data.slug ?? doc.id);
914
+ const url = `/${slug}/${slugStr}`;
915
+ const keywords = [];
916
+ if (Array.isArray(data.keywords)) {
917
+ for (const k of data.keywords) {
918
+ if (typeof k === 'string' && k.trim())
919
+ keywords.push(k);
920
+ }
921
+ }
922
+ if (Array.isArray(data.tags)) {
923
+ for (const k of data.tags) {
924
+ if (typeof k === 'string' && k.trim())
925
+ keywords.push(k);
926
+ }
927
+ }
928
+ pages.push({
929
+ id: String(doc.id),
930
+ title: titleStr,
931
+ url,
932
+ ...(keywords.length > 0 ? { keywords } : {}),
933
+ });
934
+ }
935
+ }
936
+ const suggestions = await aiModule.suggestInternalLinks(body.content, pages);
937
+ const limited = suggestions.slice(0, limit);
938
+ return json({
939
+ data: {
940
+ suggestions: limited,
941
+ candidatesScanned: pages.length,
942
+ },
943
+ });
944
+ }
945
+ catch (err) {
946
+ return internalError(err, 'ai suggest-internal-links');
947
+ }
948
+ });
949
+ /**
950
+ * Generate accessible alt text (and a derived title) for an image asset
951
+ * using the AI vision provider. Replaces the old client-side filename mock.
952
+ * Returns 501 when the AI plugin/provider isn't configured so the admin can
953
+ * fall back gracefully instead of silently failing.
954
+ */
955
+ router.post('/ai/media-metadata', async (request) => {
956
+ try {
957
+ const auth = await requireAuth(request);
958
+ if (auth.error)
959
+ return auth.error;
960
+ const scopeErr = requireMediaScope(auth.session);
961
+ if (scopeErr)
962
+ return scopeErr;
963
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-media-metadata:${auth.session.userId}`))) {
964
+ return errorResponse('AI media metadata rate limit reached. Try again in an hour.', 429);
965
+ }
966
+ const body = (await request.json());
967
+ // Resolve a safe image URL. Prefer a media id we own; only accept a raw
968
+ // URL if it passes the storage allowlist (SSRF guard).
969
+ let imageUrl = '';
970
+ let mimeType = '';
971
+ if (body.mediaId) {
972
+ const media = await db().media.findUnique({ where: { id: body.mediaId } });
973
+ if (!media)
974
+ return errorResponse('Media not found', 404);
975
+ mimeType = media.mimeType;
976
+ imageUrl = media.publicUrl ?? mediaUrl(media.storageKey);
977
+ }
978
+ else if (body.imageUrl) {
979
+ if (!isAllowedStorageUrl(body.imageUrl)) {
980
+ return errorResponse('Invalid image URL', 400);
981
+ }
982
+ imageUrl = body.imageUrl;
983
+ }
984
+ else {
985
+ return errorResponse('mediaId or imageUrl is required', 400);
986
+ }
987
+ if (mimeType && !mimeType.startsWith('image/')) {
988
+ return errorResponse('AI metadata is only available for images', 400);
989
+ }
990
+ let aiModule;
991
+ try {
992
+ aiModule = await importAIPlugin();
993
+ }
994
+ catch {
995
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to generate media metadata.', 501);
996
+ }
997
+ if (typeof aiModule.generateAltText !== 'function') {
998
+ return errorResponse('AI plugin missing `generateAltText`.', 501);
999
+ }
1000
+ // Fetch the image bytes through the SSRF-safe fetch (re-validates + DNS-
1001
+ // resolves the host, defeating rebinding, and disables redirects) so the
1002
+ // vision model analyzes the ACTUAL image instead of hallucinating from the
1003
+ // URL string. Best-effort: any failure falls back to a context heuristic.
1004
+ const image = await fetchImageForVision(imageUrl, mimeType);
1005
+ let alt = '';
1006
+ let alternatives = [];
1007
+ try {
1008
+ const r = (await aiModule.generateAltText(imageUrl, {
1009
+ pageTitle: body.pageTitle,
1010
+ ...(image ? { image } : {}),
1011
+ }));
1012
+ alt = (r?.altText ?? '').trim();
1013
+ alternatives = Array.isArray(r?.alternatives) ? r.alternatives : [];
1014
+ }
1015
+ catch (err) {
1016
+ const msg = err instanceof Error ? err.message : 'unknown';
1017
+ if (msg.toLowerCase().includes('not configured') ||
1018
+ msg.toLowerCase().includes('missing') ||
1019
+ msg.toLowerCase().includes('api key')) {
1020
+ return errorResponse(`AI provider is not configured: ${msg}`, 501);
1021
+ }
1022
+ throw err;
1023
+ }
1024
+ await logEvent({
1025
+ event: 'settings_changed',
1026
+ userId: auth.session.userId,
1027
+ details: { action: 'ai_media_metadata', mediaId: body.mediaId ?? null },
1028
+ });
1029
+ return json({ data: { alt, title: deriveMediaTitleFromAlt(alt), alternatives } });
1030
+ }
1031
+ catch (err) {
1032
+ return internalError(err, 'ai media-metadata');
1033
+ }
1034
+ });
1035
+ }
1036
+ //# sourceMappingURL=saved-sections.js.map