@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,790 @@
1
+ import { getActuateConfig } from '../../config/runtime.js';
2
+ import { guardedDb } from '../guarded-db.js';
3
+ import { json, errorResponse, internalError, normalizeRedirect, hasModel, parseCsvLine, modelNotAvailable, requireAuth, WRITE_ROLES, ADMIN_ROLES, requireRole, } from '../route-helpers.js';
4
+ export function registerRedirectRoutes(router) {
5
+ const db = guardedDb;
6
+ // ---------------------------------------------------------------------------
7
+ // Redirects routes
8
+ // ---------------------------------------------------------------------------
9
+ router.get('/redirects', async (request) => {
10
+ try {
11
+ const auth = await requireAuth(request);
12
+ if (auth.error)
13
+ return auth.error;
14
+ const redirects = await db().redirect.findMany({ orderBy: { createdAt: 'desc' } });
15
+ return json({ data: redirects.map(normalizeRedirect) });
16
+ }
17
+ catch (err) {
18
+ return internalError(err);
19
+ }
20
+ });
21
+ router.post('/redirects', async (request) => {
22
+ try {
23
+ const auth = await requireAuth(request);
24
+ if (auth.error)
25
+ return auth.error;
26
+ if (auth.session.role !== 'ADMIN')
27
+ return errorResponse('Admin access required', 403);
28
+ const body = (await request.json());
29
+ const source = String(body.source ?? body.from ?? '').trim();
30
+ const destination = String(body.destination ?? body.to ?? '').trim();
31
+ const requestedStatus = Number(body.statusCode ?? body.type);
32
+ if (!source || !destination) {
33
+ return errorResponse('source and destination are required', 400);
34
+ }
35
+ // Open-redirect defence: relative destinations (`/foo`) are always
36
+ // allowed; absolute destinations must point at an explicitly trusted
37
+ // host. We compare on parsed origins (not string `startsWith`) so
38
+ // `https://attacker.com.example.com` no longer passes a `startsWith`
39
+ // check on `https://example.com`. Configure the allowlist via
40
+ // `redirects.allowedExternalHosts` (string[] of hostnames).
41
+ if (destination.startsWith('http://') || destination.startsWith('https://')) {
42
+ let destUrl;
43
+ try {
44
+ destUrl = new URL(destination);
45
+ }
46
+ catch {
47
+ return errorResponse('Invalid destination URL', 400);
48
+ }
49
+ if (!['http:', 'https:'].includes(destUrl.protocol)) {
50
+ return errorResponse('Invalid destination URL', 400);
51
+ }
52
+ const cmsConfig = getActuateConfig();
53
+ const allowed = new Set([
54
+ ...(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
55
+ ? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
56
+ : []),
57
+ ]);
58
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
59
+ if (siteUrl) {
60
+ try {
61
+ allowed.add(new URL(siteUrl).hostname.toLowerCase());
62
+ }
63
+ catch {
64
+ /* noop */
65
+ }
66
+ }
67
+ if (!allowed.has(destUrl.hostname.toLowerCase())) {
68
+ return errorResponse('External redirect destinations must be to an allowlisted host. Add the host to `redirects.allowedExternalHosts` in your CMS config.', 400);
69
+ }
70
+ }
71
+ else if (!destination.startsWith('/')) {
72
+ return errorResponse('Destination must be an absolute URL or a path beginning with /', 400);
73
+ }
74
+ const redirect = await db().redirect.create({
75
+ data: {
76
+ source,
77
+ destination,
78
+ statusCode: [301, 302, 307, 308].includes(requestedStatus) ? requestedStatus : 301,
79
+ isRegex: body.isRegex === true,
80
+ notes: typeof body.notes === 'string' ? body.notes : null,
81
+ },
82
+ });
83
+ return json({ data: normalizeRedirect(redirect) }, 201);
84
+ }
85
+ catch (err) {
86
+ return internalError(err, 'create redirect');
87
+ }
88
+ });
89
+ router.delete('/redirects/:id', async (request, params) => {
90
+ try {
91
+ const auth = await requireAuth(request);
92
+ if (auth.error)
93
+ return auth.error;
94
+ if (auth.session.role !== 'ADMIN')
95
+ return errorResponse('Admin access required', 403);
96
+ await db().redirect.delete({ where: { id: params.id } });
97
+ return json({ data: { success: true } });
98
+ }
99
+ catch (err) {
100
+ return internalError(err, 'delete redirect');
101
+ }
102
+ });
103
+ // ---------------------------------------------------------------------------
104
+ // Redirect engine — /seo/redirects/* (CRUD, chains/loops, 404 recovery, CSV)
105
+ // ---------------------------------------------------------------------------
106
+ const REDIRECT_STATUS_CODES = [301, 302, 307, 308, 410];
107
+ function redirectToRule(r) {
108
+ return {
109
+ id: r.id,
110
+ type: Number(r.statusCode ?? 301),
111
+ fromPath: r.source ?? '',
112
+ toPath: r.destination ?? '',
113
+ enabled: r.enabled ?? true,
114
+ priority: Number(r.priority ?? 0),
115
+ preserveQuery: r.preserveQuery ?? false,
116
+ hits: Number(r.hits ?? 0),
117
+ lastHitAt: r.lastHitAt ?? null,
118
+ stale: requireRedirectGraph().isRedirectStale(r.lastHitAt),
119
+ source: r.source_kind ?? 'manual',
120
+ notes: r.notes ?? null,
121
+ createdAt: r.createdAt ?? null,
122
+ updatedAt: r.updatedAt ?? null,
123
+ };
124
+ }
125
+ // Lazily-loaded pure graph helpers (sync after first load is fine because
126
+ // the module is tiny and the dynamic import resolves before any route runs
127
+ // that needs it; we cache the namespace).
128
+ let _graph = null;
129
+ function requireRedirectGraph() {
130
+ if (!_graph)
131
+ throw new Error('redirect graph not loaded');
132
+ return _graph;
133
+ }
134
+ async function ensureRedirectGraph() {
135
+ if (!_graph)
136
+ _graph = await import('../../redirects/graph.js');
137
+ return _graph;
138
+ }
139
+ /** Validate a redirect destination (open-redirect defence). */
140
+ function validateRedirectDestination(destination, statusCode) {
141
+ if (statusCode === 410)
142
+ return null; // Gone: destination is informational
143
+ if (destination.startsWith('http://') || destination.startsWith('https://')) {
144
+ let destUrl;
145
+ try {
146
+ destUrl = new URL(destination);
147
+ }
148
+ catch {
149
+ return 'Invalid destination URL';
150
+ }
151
+ if (!['http:', 'https:'].includes(destUrl.protocol))
152
+ return 'Invalid destination URL';
153
+ const cmsConfig = getActuateConfig();
154
+ const allowed = new Set(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
155
+ ? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
156
+ : []);
157
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
158
+ if (siteUrl) {
159
+ try {
160
+ allowed.add(new URL(siteUrl).hostname.toLowerCase());
161
+ }
162
+ catch {
163
+ /* noop */
164
+ }
165
+ }
166
+ if (!allowed.has(destUrl.hostname.toLowerCase())) {
167
+ return 'External redirect destinations must be to an allowlisted host.';
168
+ }
169
+ }
170
+ else if (!destination.startsWith('/')) {
171
+ return 'Destination must be an absolute URL or a path beginning with /';
172
+ }
173
+ return null;
174
+ }
175
+ router.get('/seo/redirects', async (request) => {
176
+ try {
177
+ const auth = await requireAuth(request);
178
+ if (auth.error)
179
+ return auth.error;
180
+ await ensureRedirectGraph();
181
+ const rows = await db().redirect.findMany({
182
+ orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
183
+ });
184
+ return json({ data: { rules: rows.map(redirectToRule) } });
185
+ }
186
+ catch (err) {
187
+ return internalError(err, 'seo/redirects');
188
+ }
189
+ });
190
+ router.get('/seo/redirects/overview', async (request) => {
191
+ try {
192
+ const auth = await requireAuth(request);
193
+ if (auth.error)
194
+ return auth.error;
195
+ const graph = await ensureRedirectGraph();
196
+ const rows = await db().redirect.findMany({});
197
+ const active = rows.filter((r) => r.enabled !== false);
198
+ const stale = active.filter((r) => graph.isRedirectStale(r.lastHitAt)).length;
199
+ const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
200
+ let notFoundCount = 0;
201
+ let notFoundHits = 0;
202
+ if (hasModel(db(), 'redirect404Hit')) {
203
+ const hits = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
204
+ notFoundCount = hits.length;
205
+ notFoundHits = hits.reduce((s, h) => s + Number(h.hits ?? 0), 0);
206
+ }
207
+ const trafficRecovered = active.reduce((s, r) => s + Number(r.hits ?? 0), 0);
208
+ return json({
209
+ data: {
210
+ activeRedirects: active.length,
211
+ staleRedirects: stale,
212
+ notFoundCount,
213
+ notFoundHits,
214
+ chainsAndLoops: chains.length,
215
+ trafficRecovered,
216
+ },
217
+ });
218
+ }
219
+ catch (err) {
220
+ return internalError(err, 'seo/redirects/overview');
221
+ }
222
+ });
223
+ router.get('/seo/redirects/chains', async (request) => {
224
+ try {
225
+ const auth = await requireAuth(request);
226
+ if (auth.error)
227
+ return auth.error;
228
+ const graph = await ensureRedirectGraph();
229
+ const rows = await db().redirect.findMany({});
230
+ const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
231
+ return json({ data: { chains } });
232
+ }
233
+ catch (err) {
234
+ return internalError(err, 'seo/redirects/chains');
235
+ }
236
+ });
237
+ router.post('/seo/redirects/flatten', async (request) => {
238
+ try {
239
+ const auth = await requireAuth(request);
240
+ if (auth.error)
241
+ return auth.error;
242
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
243
+ if (roleErr)
244
+ return roleErr;
245
+ const graph = await ensureRedirectGraph();
246
+ const body = (await request.json().catch(() => ({})));
247
+ const rows = await db().redirect.findMany({});
248
+ const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
249
+ const chain = chains.find((c) => c.id === body.chainId);
250
+ if (!chain)
251
+ return errorResponse('Chain not found', 404);
252
+ if (chain.kind === 'loop') {
253
+ return errorResponse('Cannot auto-flatten a loop — break the cycle manually.', 400);
254
+ }
255
+ // Point the chain source directly at the terminal destination.
256
+ const sourceRow = rows.find((r) => graph.normalizeRedirectPath(r.source) === chain.suggestedFrom);
257
+ if (!sourceRow)
258
+ return errorResponse('Chain source rule not found', 404);
259
+ const updated = await db().redirect.update({
260
+ where: { id: sourceRow.id },
261
+ data: { destination: chain.suggestedTo, source_kind: 'chain-flatten' },
262
+ });
263
+ return json({ data: redirectToRule(updated) });
264
+ }
265
+ catch (err) {
266
+ return internalError(err, 'seo/redirects/flatten');
267
+ }
268
+ });
269
+ router.get('/seo/redirects/suggestions', async (request) => {
270
+ try {
271
+ const auth = await requireAuth(request);
272
+ if (auth.error)
273
+ return auth.error;
274
+ if (!hasModel(db(), 'redirectSuggestion'))
275
+ return json({ data: { suggestions: [] } });
276
+ const rows = await db().redirectSuggestion.findMany({
277
+ where: { status: 'open' },
278
+ orderBy: { confidence: 'desc' },
279
+ });
280
+ let hitsByPath = new Map();
281
+ if (hasModel(db(), 'redirect404Hit')) {
282
+ const hits = await db().redirect404Hit.findMany({});
283
+ hitsByPath = new Map(hits.map((h) => [h.path, Number(h.hits ?? 0)]));
284
+ }
285
+ return json({
286
+ data: {
287
+ suggestions: rows.map((s) => ({
288
+ id: s.id,
289
+ fromPath: s.fromPath,
290
+ toPath: s.toPath,
291
+ // Stored as a 0-100 Int; the UI contract is a 0-1 fraction.
292
+ confidence: Number(s.confidence ?? 0) / 100,
293
+ reason: s.reason ?? null,
294
+ status: s.status,
295
+ hits: hitsByPath.get(s.fromPath) ?? 0,
296
+ trend: null,
297
+ })),
298
+ },
299
+ });
300
+ }
301
+ catch (err) {
302
+ return internalError(err, 'seo/redirects/suggestions');
303
+ }
304
+ });
305
+ router.post('/seo/redirects/suggest', async (request) => {
306
+ try {
307
+ const auth = await requireAuth(request);
308
+ if (auth.error)
309
+ return auth.error;
310
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
311
+ if (roleErr)
312
+ return roleErr;
313
+ if (!hasModel(db(), 'redirectSuggestion') || !hasModel(db(), 'redirect404Hit')) {
314
+ return modelNotAvailable('RedirectSuggestion');
315
+ }
316
+ const { suggestRedirectTarget } = await import('../../redirects/suggest.js');
317
+ // Candidate set: published page/post URLs.
318
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
319
+ const entities = await gatherAuditEntities(db(), {
320
+ collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
321
+ });
322
+ const candidates = entities.map((e) => ({ url: e.url, title: e.title }));
323
+ const unresolved = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
324
+ let created = 0;
325
+ for (const hit of unresolved) {
326
+ const existing = await db().redirectSuggestion.findFirst({
327
+ where: { fromPath: hit.path, status: 'open' },
328
+ });
329
+ if (existing)
330
+ continue;
331
+ const suggestion = suggestRedirectTarget(hit.path, candidates);
332
+ if (!suggestion)
333
+ continue;
334
+ await db().redirectSuggestion.create({
335
+ data: {
336
+ fromPath: hit.path,
337
+ toPath: suggestion.toPath,
338
+ // DB column is an Int — store confidence as a 0-100 percentage.
339
+ // The GET endpoint converts back to the 0-1 fraction the UI expects.
340
+ confidence: Math.round(suggestion.confidence * 100),
341
+ reason: suggestion.reason,
342
+ status: 'open',
343
+ source: 'ai-404-recovery',
344
+ },
345
+ });
346
+ created++;
347
+ }
348
+ return json({ data: { created } });
349
+ }
350
+ catch (err) {
351
+ return internalError(err, 'seo/redirects/suggest');
352
+ }
353
+ });
354
+ router.post('/seo/redirects/suggestions/:id/accept', async (request, params) => {
355
+ try {
356
+ const auth = await requireAuth(request);
357
+ if (auth.error)
358
+ return auth.error;
359
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
360
+ if (roleErr)
361
+ return roleErr;
362
+ if (!hasModel(db(), 'redirectSuggestion'))
363
+ return modelNotAvailable('RedirectSuggestion');
364
+ const suggestion = await db().redirectSuggestion.findUnique({ where: { id: params.id } });
365
+ if (!suggestion)
366
+ return errorResponse('Suggestion not found', 404);
367
+ const graph = await ensureRedirectGraph();
368
+ const rows = await db().redirect.findMany({});
369
+ if (graph.wouldCreateLoop(rows.map((r) => ({
370
+ id: r.id,
371
+ from: r.source,
372
+ to: r.destination,
373
+ enabled: r.enabled,
374
+ })), suggestion.fromPath, suggestion.toPath)) {
375
+ return errorResponse('Accepting this suggestion would create a redirect loop.', 400);
376
+ }
377
+ await db().redirect.create({
378
+ data: {
379
+ source: suggestion.fromPath,
380
+ destination: suggestion.toPath,
381
+ statusCode: 301,
382
+ enabled: true,
383
+ source_kind: 'ai-404-recovery',
384
+ createdById: auth.session.userId,
385
+ },
386
+ });
387
+ await db().redirectSuggestion.update({
388
+ where: { id: suggestion.id },
389
+ data: { status: 'accepted' },
390
+ });
391
+ // Mark the 404 resolved.
392
+ if (hasModel(db(), 'redirect404Hit')) {
393
+ await db()
394
+ .redirect404Hit.updateMany({
395
+ where: { path: suggestion.fromPath },
396
+ data: { resolvedAt: new Date() },
397
+ })
398
+ .catch(() => { });
399
+ }
400
+ return json({ data: { success: true } });
401
+ }
402
+ catch (err) {
403
+ return internalError(err, 'seo/redirects/suggestions/accept');
404
+ }
405
+ });
406
+ router.post('/seo/redirects/suggestions/:id/dismiss', async (request, params) => {
407
+ try {
408
+ const auth = await requireAuth(request);
409
+ if (auth.error)
410
+ return auth.error;
411
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
412
+ if (roleErr)
413
+ return roleErr;
414
+ if (!hasModel(db(), 'redirectSuggestion'))
415
+ return modelNotAvailable('RedirectSuggestion');
416
+ await db().redirectSuggestion.update({
417
+ where: { id: params.id },
418
+ data: { status: 'dismissed' },
419
+ });
420
+ return json({ data: { success: true } });
421
+ }
422
+ catch (err) {
423
+ return internalError(err, 'seo/redirects/suggestions/dismiss');
424
+ }
425
+ });
426
+ router.get('/seo/redirects/export', async (request) => {
427
+ try {
428
+ const auth = await requireAuth(request);
429
+ if (auth.error)
430
+ return auth.error;
431
+ const rows = await db().redirect.findMany({ orderBy: { createdAt: 'desc' } });
432
+ const header = 'from,to,type,enabled';
433
+ const lines = rows.map((r) => {
434
+ const esc = (v) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
435
+ return [
436
+ esc(String(r.source ?? '')),
437
+ esc(String(r.destination ?? '')),
438
+ String(r.statusCode ?? 301),
439
+ r.enabled === false ? 'false' : 'true',
440
+ ].join(',');
441
+ });
442
+ return json({ data: { csv: [header, ...lines].join('\n') } });
443
+ }
444
+ catch (err) {
445
+ return internalError(err, 'seo/redirects/export');
446
+ }
447
+ });
448
+ router.post('/seo/redirects/import', async (request) => {
449
+ try {
450
+ const auth = await requireAuth(request);
451
+ if (auth.error)
452
+ return auth.error;
453
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
454
+ if (roleErr)
455
+ return roleErr;
456
+ const graph = await ensureRedirectGraph();
457
+ const body = (await request.json().catch(() => ({})));
458
+ const csv = String(body.csv ?? '').trim();
459
+ if (!csv)
460
+ return errorResponse('CSV body is required', 400);
461
+ const lines = csv.split(/\r?\n/).filter((l) => l.trim().length > 0);
462
+ // Skip an optional header row.
463
+ if (lines[0] && /from\s*,\s*to/i.test(lines[0]))
464
+ lines.shift();
465
+ const errors = [];
466
+ let imported = 0;
467
+ const existing = await db().redirect.findMany({});
468
+ const working = existing.map((r) => ({
469
+ id: r.id,
470
+ from: r.source,
471
+ to: r.destination,
472
+ enabled: r.enabled,
473
+ }));
474
+ for (let idx = 0; idx < lines.length; idx++) {
475
+ const cols = parseCsvLine(lines[idx]);
476
+ const from = (cols[0] ?? '').trim();
477
+ const to = (cols[1] ?? '').trim();
478
+ const type = Number(cols[2] ?? 301);
479
+ if (!from || !to) {
480
+ errors.push(`Row ${idx + 1}: missing from/to`);
481
+ continue;
482
+ }
483
+ const destErr = validateRedirectDestination(to, type);
484
+ if (destErr) {
485
+ errors.push(`Row ${idx + 1}: ${destErr}`);
486
+ continue;
487
+ }
488
+ if (graph.wouldCreateLoop(working, from, to)) {
489
+ errors.push(`Row ${idx + 1}: would create a redirect loop`);
490
+ continue;
491
+ }
492
+ try {
493
+ await db().redirect.upsert({
494
+ where: { source: from },
495
+ update: {
496
+ destination: to,
497
+ statusCode: REDIRECT_STATUS_CODES.includes(type) ? type : 301,
498
+ },
499
+ create: {
500
+ source: from,
501
+ destination: to,
502
+ statusCode: REDIRECT_STATUS_CODES.includes(type) ? type : 301,
503
+ source_kind: 'import',
504
+ createdById: auth.session.userId,
505
+ },
506
+ });
507
+ working.push({ id: `imp-${idx}`, from, to, enabled: true });
508
+ imported++;
509
+ }
510
+ catch (e) {
511
+ errors.push(`Row ${idx + 1}: ${e instanceof Error ? e.message : 'insert failed'}`);
512
+ }
513
+ }
514
+ return json({ data: { imported, errors } });
515
+ }
516
+ catch (err) {
517
+ return internalError(err, 'seo/redirects/import');
518
+ }
519
+ });
520
+ router.post('/seo/redirects', async (request) => {
521
+ try {
522
+ const auth = await requireAuth(request);
523
+ if (auth.error)
524
+ return auth.error;
525
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
526
+ if (roleErr)
527
+ return roleErr;
528
+ const graph = await ensureRedirectGraph();
529
+ const body = (await request.json().catch(() => ({})));
530
+ const source = String(body.fromPath ?? body.source ?? '').trim();
531
+ const destination = String(body.toPath ?? body.destination ?? '').trim();
532
+ const statusCode = Number(body.type ?? body.statusCode ?? 301);
533
+ if (!source)
534
+ return errorResponse('fromPath is required', 400);
535
+ if (!destination && statusCode !== 410)
536
+ return errorResponse('toPath is required', 400);
537
+ if (!REDIRECT_STATUS_CODES.includes(statusCode)) {
538
+ return errorResponse('type must be one of 301, 302, 307, 308, 410', 400);
539
+ }
540
+ const destErr = validateRedirectDestination(destination, statusCode);
541
+ if (destErr)
542
+ return errorResponse(destErr, 400);
543
+ const rows = await db().redirect.findMany({});
544
+ if (rows.some((r) => graph.normalizeRedirectPath(r.source) === graph.normalizeRedirectPath(source))) {
545
+ return errorResponse('A redirect with this source already exists.', 409);
546
+ }
547
+ if (graph.wouldCreateLoop(rows.map((r) => ({
548
+ id: r.id,
549
+ from: r.source,
550
+ to: r.destination,
551
+ enabled: r.enabled,
552
+ })), source, destination || source)) {
553
+ return errorResponse('This redirect would create a loop.', 400);
554
+ }
555
+ const created = await db().redirect.create({
556
+ data: {
557
+ source,
558
+ destination: destination || source,
559
+ statusCode,
560
+ enabled: body.enabled !== false,
561
+ preserveQuery: body.preserveQuery === true,
562
+ notes: typeof body.notes === 'string' ? body.notes : null,
563
+ source_kind: 'manual',
564
+ createdById: auth.session.userId,
565
+ },
566
+ });
567
+ return json({ data: redirectToRule(created) }, 201);
568
+ }
569
+ catch (err) {
570
+ return internalError(err, 'seo/redirects POST');
571
+ }
572
+ });
573
+ router.get('/seo/redirects/:id', async (request, params) => {
574
+ try {
575
+ const auth = await requireAuth(request);
576
+ if (auth.error)
577
+ return auth.error;
578
+ await ensureRedirectGraph();
579
+ const row = await db().redirect.findUnique({ where: { id: params.id } });
580
+ if (!row)
581
+ return errorResponse('Redirect not found', 404);
582
+ return json({ data: redirectToRule(row) });
583
+ }
584
+ catch (err) {
585
+ return internalError(err, 'seo/redirects/:id GET');
586
+ }
587
+ });
588
+ router.put('/seo/redirects/:id', async (request, params) => {
589
+ try {
590
+ const auth = await requireAuth(request);
591
+ if (auth.error)
592
+ return auth.error;
593
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
594
+ if (roleErr)
595
+ return roleErr;
596
+ const graph = await ensureRedirectGraph();
597
+ const existing = await db().redirect.findUnique({ where: { id: params.id } });
598
+ if (!existing)
599
+ return errorResponse('Redirect not found', 404);
600
+ const body = (await request.json().catch(() => ({})));
601
+ const source = body.fromPath !== undefined || body.source !== undefined
602
+ ? String(body.fromPath ?? body.source ?? '').trim()
603
+ : existing.source;
604
+ const destination = body.toPath !== undefined || body.destination !== undefined
605
+ ? String(body.toPath ?? body.destination ?? '').trim()
606
+ : existing.destination;
607
+ const statusCode = body.type !== undefined || body.statusCode !== undefined
608
+ ? Number(body.type ?? body.statusCode)
609
+ : existing.statusCode;
610
+ if (!source)
611
+ return errorResponse('fromPath is required', 400);
612
+ if (!REDIRECT_STATUS_CODES.includes(statusCode)) {
613
+ return errorResponse('type must be one of 301, 302, 307, 308, 410', 400);
614
+ }
615
+ const destErr = validateRedirectDestination(destination, statusCode);
616
+ if (destErr)
617
+ return errorResponse(destErr, 400);
618
+ const rows = await db().redirect.findMany({});
619
+ if (graph.wouldCreateLoop(rows.map((r) => ({
620
+ id: r.id,
621
+ from: r.source,
622
+ to: r.destination,
623
+ enabled: r.enabled,
624
+ })), source, destination || source, existing.id)) {
625
+ return errorResponse('This change would create a redirect loop.', 400);
626
+ }
627
+ const updated = await db().redirect.update({
628
+ where: { id: existing.id },
629
+ data: {
630
+ source,
631
+ destination: destination || source,
632
+ statusCode,
633
+ ...(body.enabled !== undefined ? { enabled: body.enabled === true } : {}),
634
+ ...(body.preserveQuery !== undefined
635
+ ? { preserveQuery: body.preserveQuery === true }
636
+ : {}),
637
+ ...(body.notes !== undefined
638
+ ? { notes: typeof body.notes === 'string' ? body.notes : null }
639
+ : {}),
640
+ },
641
+ });
642
+ return json({ data: redirectToRule(updated) });
643
+ }
644
+ catch (err) {
645
+ return internalError(err, 'seo/redirects/:id PUT');
646
+ }
647
+ });
648
+ router.delete('/seo/redirects/:id', async (request, params) => {
649
+ try {
650
+ const auth = await requireAuth(request);
651
+ if (auth.error)
652
+ return auth.error;
653
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
654
+ if (roleErr)
655
+ return roleErr;
656
+ await db().redirect.delete({ where: { id: params.id } });
657
+ return json({ data: { success: true } });
658
+ }
659
+ catch (err) {
660
+ return internalError(err, 'seo/redirects/:id DELETE');
661
+ }
662
+ });
663
+ // Public: resolve a path against enabled redirects. Used by the redirect
664
+ // middleware. Increments hit counters on a match; logs a 404 otherwise.
665
+ router.get('/redirects/resolve', async (request) => {
666
+ try {
667
+ const graph = await ensureRedirectGraph();
668
+ const url = new URL(request.url);
669
+ const path = url.searchParams.get('path') ?? '';
670
+ if (!path)
671
+ return json({ data: { match: null } });
672
+ const norm = graph.normalizeRedirectPath(path);
673
+ const rows = await db().redirect.findMany({
674
+ where: { enabled: true },
675
+ orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
676
+ });
677
+ const match = rows.find((r) => graph.normalizeRedirectPath(r.source) === norm);
678
+ if (!match) {
679
+ return json({ data: { match: null } });
680
+ }
681
+ db()
682
+ .redirect.update({
683
+ where: { id: match.id },
684
+ data: { hits: { increment: 1 }, lastHitAt: new Date() },
685
+ })
686
+ .catch(() => { });
687
+ return json({
688
+ data: {
689
+ match: {
690
+ to: match.destination,
691
+ statusCode: Number(match.statusCode ?? 301),
692
+ preserveQuery: match.preserveQuery ?? false,
693
+ },
694
+ },
695
+ });
696
+ }
697
+ catch (err) {
698
+ return internalError(err, 'redirects/resolve');
699
+ }
700
+ });
701
+ // Public: enabled redirects in the plugin-redirects middleware shape. Backs
702
+ // `createRedirectMiddleware`'s `getRedirects` (cached by the factory).
703
+ router.get('/redirects/public', async () => {
704
+ try {
705
+ const rows = await db().redirect.findMany({
706
+ where: { enabled: true },
707
+ orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
708
+ select: {
709
+ id: true,
710
+ source: true,
711
+ destination: true,
712
+ statusCode: true,
713
+ preserveQuery: true,
714
+ },
715
+ });
716
+ return json({
717
+ data: {
718
+ redirects: rows.map((r) => ({
719
+ id: r.id,
720
+ fromUrl: r.source,
721
+ toUrl: r.destination,
722
+ type: String([301, 302].includes(Number(r.statusCode)) ? r.statusCode : 301),
723
+ statusCode: Number(r.statusCode ?? 301),
724
+ preserveQuery: r.preserveQuery ?? false,
725
+ isActive: true,
726
+ })),
727
+ },
728
+ });
729
+ }
730
+ catch (err) {
731
+ return internalError(err, 'redirects/public');
732
+ }
733
+ });
734
+ // Public: increment hit counters for a matched source path. Fire-and-forget
735
+ // from the middleware so request latency is unaffected.
736
+ router.post('/redirects/record-hit', async (request) => {
737
+ try {
738
+ const graph = await ensureRedirectGraph();
739
+ const body = (await request.json().catch(() => ({})));
740
+ const norm = graph.normalizeRedirectPath(String(body.path ?? ''));
741
+ if (!norm)
742
+ return json({ data: { ok: false } });
743
+ const rows = await db().redirect.findMany({
744
+ where: { enabled: true },
745
+ select: { id: true, source: true },
746
+ });
747
+ const match = rows.find((r) => graph.normalizeRedirectPath(r.source) === norm);
748
+ if (!match)
749
+ return json({ data: { ok: false } });
750
+ await db()
751
+ .redirect.update({
752
+ where: { id: match.id },
753
+ data: { hits: { increment: 1 }, lastHitAt: new Date() },
754
+ })
755
+ .catch(() => { });
756
+ return json({ data: { ok: true } });
757
+ }
758
+ catch (err) {
759
+ return internalError(err, 'redirects/record-hit');
760
+ }
761
+ });
762
+ // Public: record a 404 for a path (called by the site when it renders
763
+ // notFound). Aggregates per-path so the Redirects tab can surface dead URLs.
764
+ router.post('/redirects/record-404', async (request) => {
765
+ try {
766
+ if (!hasModel(db(), 'redirect404Hit'))
767
+ return json({ data: { ok: false } });
768
+ const graph = await ensureRedirectGraph();
769
+ const body = (await request.json().catch(() => ({})));
770
+ const path = graph.normalizeRedirectPath(String(body.path ?? ''));
771
+ // Bound the inputs: this endpoint is unauthenticated, so cap the stored
772
+ // strings to keep a flood of distinct paths from bloating the table.
773
+ if (!path || path === '/' || path.length > 2048)
774
+ return json({ data: { ok: false } });
775
+ const referrer = typeof body.referrer === 'string' && body.referrer.length > 0
776
+ ? body.referrer.slice(0, 2048)
777
+ : null;
778
+ await db().redirect404Hit.upsert({
779
+ where: { path },
780
+ update: { hits: { increment: 1 }, lastSeenAt: new Date() },
781
+ create: { path, hits: 1, referrer },
782
+ });
783
+ return json({ data: { ok: true } });
784
+ }
785
+ catch (err) {
786
+ return internalError(err, 'redirects/record-404');
787
+ }
788
+ });
789
+ }
790
+ //# sourceMappingURL=redirects.js.map