@actuate-media/cms-core 0.30.0 → 0.32.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 (98) hide show
  1. package/dist/__tests__/api/admin-contracts.test.js +10 -2
  2. package/dist/__tests__/api/admin-contracts.test.js.map +1 -1
  3. package/dist/__tests__/api/public-forms.test.d.ts +2 -0
  4. package/dist/__tests__/api/public-forms.test.d.ts.map +1 -0
  5. package/dist/__tests__/api/public-forms.test.js +125 -0
  6. package/dist/__tests__/api/public-forms.test.js.map +1 -0
  7. package/dist/__tests__/api/public-seo.test.js +2 -0
  8. package/dist/__tests__/api/public-seo.test.js.map +1 -1
  9. package/dist/__tests__/forms/forms-service.test.d.ts +2 -0
  10. package/dist/__tests__/forms/forms-service.test.d.ts.map +1 -0
  11. package/dist/__tests__/forms/forms-service.test.js +193 -0
  12. package/dist/__tests__/forms/forms-service.test.js.map +1 -0
  13. package/dist/__tests__/forms/spam.test.d.ts +2 -0
  14. package/dist/__tests__/forms/spam.test.d.ts.map +1 -0
  15. package/dist/__tests__/forms/spam.test.js +115 -0
  16. package/dist/__tests__/forms/spam.test.js.map +1 -0
  17. package/dist/__tests__/forms/submission-files.test.d.ts +2 -0
  18. package/dist/__tests__/forms/submission-files.test.d.ts.map +1 -0
  19. package/dist/__tests__/forms/submission-files.test.js +65 -0
  20. package/dist/__tests__/forms/submission-files.test.js.map +1 -0
  21. package/dist/__tests__/forms/submission-pipeline.test.d.ts +2 -0
  22. package/dist/__tests__/forms/submission-pipeline.test.d.ts.map +1 -0
  23. package/dist/__tests__/forms/submission-pipeline.test.js +131 -0
  24. package/dist/__tests__/forms/submission-pipeline.test.js.map +1 -0
  25. package/dist/__tests__/forms/submission-validation.test.d.ts +2 -0
  26. package/dist/__tests__/forms/submission-validation.test.d.ts.map +1 -0
  27. package/dist/__tests__/forms/submission-validation.test.js +108 -0
  28. package/dist/__tests__/forms/submission-validation.test.js.map +1 -0
  29. package/dist/__tests__/seo/audit-runner.test.js +54 -1
  30. package/dist/__tests__/seo/audit-runner.test.js.map +1 -1
  31. package/dist/api/handler-factory.d.ts.map +1 -1
  32. package/dist/api/handler-factory.js +2 -2
  33. package/dist/api/handler-factory.js.map +1 -1
  34. package/dist/api/handlers.d.ts.map +1 -1
  35. package/dist/api/handlers.js +724 -155
  36. package/dist/api/handlers.js.map +1 -1
  37. package/dist/api/router.d.ts +2 -1
  38. package/dist/api/router.d.ts.map +1 -1
  39. package/dist/api/router.js +3 -0
  40. package/dist/api/router.js.map +1 -1
  41. package/dist/config/types.d.ts +42 -0
  42. package/dist/config/types.d.ts.map +1 -1
  43. package/dist/cron/index.d.ts +1 -0
  44. package/dist/cron/index.d.ts.map +1 -1
  45. package/dist/cron/index.js +10 -1
  46. package/dist/cron/index.js.map +1 -1
  47. package/dist/forms/entries.d.ts +45 -0
  48. package/dist/forms/entries.d.ts.map +1 -0
  49. package/dist/forms/entries.js +348 -0
  50. package/dist/forms/entries.js.map +1 -0
  51. package/dist/forms/files.d.ts +38 -0
  52. package/dist/forms/files.d.ts.map +1 -0
  53. package/dist/forms/files.js +134 -0
  54. package/dist/forms/files.js.map +1 -0
  55. package/dist/forms/index.d.ts +8 -0
  56. package/dist/forms/index.d.ts.map +1 -1
  57. package/dist/forms/index.js +7 -0
  58. package/dist/forms/index.js.map +1 -1
  59. package/dist/forms/service.d.ts +92 -0
  60. package/dist/forms/service.d.ts.map +1 -0
  61. package/dist/forms/service.js +579 -0
  62. package/dist/forms/service.js.map +1 -0
  63. package/dist/forms/spam.d.ts +33 -0
  64. package/dist/forms/spam.d.ts.map +1 -0
  65. package/dist/forms/spam.js +93 -0
  66. package/dist/forms/spam.js.map +1 -0
  67. package/dist/forms/submission.d.ts +95 -0
  68. package/dist/forms/submission.d.ts.map +1 -0
  69. package/dist/forms/submission.js +282 -0
  70. package/dist/forms/submission.js.map +1 -0
  71. package/dist/forms/types.d.ts +244 -0
  72. package/dist/forms/types.d.ts.map +1 -0
  73. package/dist/forms/types.js +36 -0
  74. package/dist/forms/types.js.map +1 -0
  75. package/dist/forms/validation.d.ts +19 -0
  76. package/dist/forms/validation.d.ts.map +1 -0
  77. package/dist/forms/validation.js +235 -0
  78. package/dist/forms/validation.js.map +1 -0
  79. package/dist/index.d.ts +2 -2
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +1 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/security/client-ip.d.ts +11 -21
  84. package/dist/security/client-ip.d.ts.map +1 -1
  85. package/dist/security/client-ip.js +38 -0
  86. package/dist/security/client-ip.js.map +1 -1
  87. package/dist/seo/audit-runner.d.ts +37 -2
  88. package/dist/seo/audit-runner.d.ts.map +1 -1
  89. package/dist/seo/audit-runner.js +56 -6
  90. package/dist/seo/audit-runner.js.map +1 -1
  91. package/dist/seo/index.d.ts +2 -1
  92. package/dist/seo/index.d.ts.map +1 -1
  93. package/dist/seo/index.js +1 -1
  94. package/dist/seo/index.js.map +1 -1
  95. package/package.json +1 -1
  96. package/prisma/cms-schema.prisma +81 -7
  97. package/prisma/migrations/0010_forms/migration.sql +130 -0
  98. package/prisma/schema.prisma +130 -35
@@ -23,6 +23,11 @@ import { schedulingCronHandler } from '../scheduling/index.js';
23
23
  import { isAuthorizedCronRequest, processCleanup, processSeoScan } from '../cron/index.js';
24
24
  import { processContentHealthScan } from '../content-health/cron.js';
25
25
  import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
26
+ import { listForms as listFormsService, getFormById as getFormByIdService, getFormBySlug as getFormBySlugService, createForm as createFormService, updateForm as updateFormService, duplicateForm as duplicateFormService, deleteForm as deleteFormService, saveFormSchema as saveFormSchemaService, getFormSchema as getFormSchemaService, getNotificationSettings as getFormNotificationSettingsService, updateNotificationSettings as updateFormNotificationSettingsService, setNotifyEnabled as setFormNotifyEnabledService, getEmbedSettings as getFormEmbedSettingsService, updateEmbedSettings as updateFormEmbedSettingsService, getFormStats as getFormStatsService, getFormsSidebarCounts as getFormsSidebarCountsService, FormNotFoundError, FormValidationError, FormConflictError, FormHasSubmissionsError, } from '../forms/service.js';
27
+ import { listEntries as listEntriesService, getEntryWithSchema as getEntryWithSchemaService, getEntryCounts as getEntryCountsService, updateEntry as updateEntryService, bulkUpdateEntries as bulkUpdateEntriesService, deleteEntry as deleteEntryService, } from '../forms/entries.js';
28
+ import { processSubmission, buildPublicForm, SubmissionValidationError, } from '../forms/submission.js';
29
+ import { validateSubmissionFiles, FORM_UPLOAD_ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_BYTES, } from '../forms/files.js';
30
+ import { resolveSubmissionFields } from '../forms/submission.js';
26
31
  import { checkForUpdates } from '../upgrade/version-check.js';
27
32
  import { createUpgradePR } from '../upgrade/upgrade-pr.js';
28
33
  import { encryptField, decryptField } from '../security/encrypted-fields.js';
@@ -246,17 +251,6 @@ function asRecord(value) {
246
251
  ? value
247
252
  : {};
248
253
  }
249
- function normalizeFormDocument(form, submissions = 0) {
250
- const data = asRecord(form.data);
251
- const fields = Array.isArray(data.fields) ? data.fields.length : Number(data.fields ?? 0);
252
- return {
253
- ...form,
254
- name: data.name ?? form.name ?? form.title ?? 'Untitled form',
255
- description: data.description ?? form.description ?? '',
256
- fields,
257
- submissions,
258
- };
259
- }
260
254
  function normalizeSubmission(submission) {
261
255
  const data = asRecord(submission.data);
262
256
  const attribution = asRecord(submission.attribution);
@@ -281,6 +275,288 @@ function normalizeSubmission(submission) {
281
275
  },
282
276
  };
283
277
  }
278
+ /**
279
+ * Map a forms-service domain error to an HTTP response. Returns `null` for
280
+ * unrecognised errors so the caller can fall through to `internalError`.
281
+ */
282
+ function mapFormError(err) {
283
+ if (err instanceof FormNotFoundError)
284
+ return errorResponse(err.message, 404);
285
+ if (err instanceof FormValidationError) {
286
+ return json({ error: err.message, code: 'INVALID_SCHEMA', details: err.errors }, 400);
287
+ }
288
+ if (err instanceof FormConflictError)
289
+ return errorResponse(err.message, 409);
290
+ if (err instanceof FormHasSubmissionsError) {
291
+ return json({ error: err.message, code: 'FORM_HAS_SUBMISSIONS', submissionCount: err.submissionCount }, 409);
292
+ }
293
+ return null;
294
+ }
295
+ function parseFormsQuery(url) {
296
+ const sp = url.searchParams;
297
+ const status = sp.get('status');
298
+ const notify = sp.get('notifyEnabled');
299
+ return {
300
+ search: sp.get('search') ?? undefined,
301
+ status: status === 'active' || status === 'draft' || status === 'archived' ? status : undefined,
302
+ notifyEnabled: notify == null ? undefined : notify === 'true',
303
+ sortBy: sp.get('sortBy') ?? undefined,
304
+ sortDirection: sp.get('sortDirection') === 'asc'
305
+ ? 'asc'
306
+ : sp.get('sortDirection') === 'desc'
307
+ ? 'desc'
308
+ : undefined,
309
+ page: sp.get('page') ? Number(sp.get('page')) : undefined,
310
+ pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
311
+ };
312
+ }
313
+ function parseEntriesQuery(url) {
314
+ const sp = url.searchParams;
315
+ const spam = sp.get('spamStatus');
316
+ const boolParam = (key) => {
317
+ const v = sp.get(key);
318
+ return v == null ? undefined : v === 'true';
319
+ };
320
+ return {
321
+ search: sp.get('search') ?? undefined,
322
+ formId: sp.get('formId') ?? sp.get('form') ?? undefined,
323
+ unread: boolParam('unread'),
324
+ starred: boolParam('starred'),
325
+ archived: boolParam('archived'),
326
+ spamStatus: spam === 'clean' || spam === 'suspected' || spam === 'spam'
327
+ ? spam
328
+ : undefined,
329
+ dateFrom: sp.get('dateFrom') ?? undefined,
330
+ dateTo: sp.get('dateTo') ?? undefined,
331
+ thisWeek: boolParam('thisWeek'),
332
+ sortBy: sp.get('sortBy') ?? undefined,
333
+ sortDirection: sp.get('sortDirection') === 'asc' ? 'asc' : undefined,
334
+ page: sp.get('page') ? Number(sp.get('page')) : undefined,
335
+ pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
336
+ };
337
+ }
338
+ // ─── Public form submission helpers ─────────────────────────────────────────
339
+ /**
340
+ * Build CORS headers for the public form endpoints. When the form has no
341
+ * embed allowlist we reflect the requesting origin (open embedding). When an
342
+ * allowlist is set we only echo the origin if it matches, so a disallowed site
343
+ * cannot read the response. Browser-level CORS is complemented by a hard
344
+ * server-side origin check in the submit handler.
345
+ */
346
+ function formCorsHeaders(request, allowedDomains) {
347
+ const origin = request.headers.get('origin');
348
+ const headers = {
349
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
350
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
351
+ 'Access-Control-Max-Age': '600',
352
+ Vary: 'Origin',
353
+ };
354
+ if (!allowedDomains || allowedDomains.length === 0) {
355
+ headers['Access-Control-Allow-Origin'] = origin || '*';
356
+ }
357
+ else if (origin && isOriginAllowed(origin, allowedDomains)) {
358
+ headers['Access-Control-Allow-Origin'] = origin;
359
+ }
360
+ return headers;
361
+ }
362
+ function normalizeAllowedHost(domain) {
363
+ return domain
364
+ .trim()
365
+ .toLowerCase()
366
+ .replace(/^https?:\/\//, '')
367
+ .replace(/\/.*$/, '')
368
+ .replace(/^\*\./, '');
369
+ }
370
+ function isOriginAllowed(origin, allowedDomains) {
371
+ let host;
372
+ try {
373
+ host = new URL(origin).hostname.toLowerCase();
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ return allowedDomains
379
+ .map(normalizeAllowedHost)
380
+ .some((a) => a !== '' && (host === a || host.endsWith(`.${a}`)));
381
+ }
382
+ function jsonWithHeaders(data, status, extra) {
383
+ return new Response(JSON.stringify(data), {
384
+ status,
385
+ headers: { ...SECURITY_HEADERS, ...extra },
386
+ });
387
+ }
388
+ /**
389
+ * Parse a public submission request into field values, files, and metadata.
390
+ * Supports both `application/json` (no files) and `multipart/form-data`
391
+ * (files + fields). Unknown/oversized bodies are rejected by the caller's
392
+ * Content-Length gate before this runs.
393
+ */
394
+ async function parseSubmissionRequest(request) {
395
+ const contentType = request.headers.get('content-type') ?? '';
396
+ if (contentType.includes('multipart/form-data')) {
397
+ const form = await request.formData();
398
+ const values = {};
399
+ const files = [];
400
+ let attribution = null;
401
+ let elapsedMs = null;
402
+ let pageUrl = null;
403
+ let captchaToken = '';
404
+ for (const [key, value] of form.entries()) {
405
+ if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
406
+ const file = value;
407
+ if (file.size === 0 && !file.name)
408
+ continue;
409
+ const buffer = Buffer.from(await file.arrayBuffer());
410
+ files.push({
411
+ fieldKey: key,
412
+ fileName: file.name || 'file',
413
+ mimeType: file.type || 'application/octet-stream',
414
+ size: buffer.byteLength,
415
+ buffer,
416
+ });
417
+ continue;
418
+ }
419
+ const str = String(value);
420
+ if (key === '__attribution') {
421
+ attribution = safeJsonObject(str);
422
+ }
423
+ else if (key === '__elapsedMs') {
424
+ const n = Number(str);
425
+ elapsedMs = Number.isFinite(n) ? n : null;
426
+ }
427
+ else if (key === '__pageUrl') {
428
+ pageUrl = str || null;
429
+ }
430
+ else if (key === '__captchaToken' || key === 'captchaToken') {
431
+ captchaToken = str;
432
+ }
433
+ else if (Object.prototype.hasOwnProperty.call(values, key)) {
434
+ // Repeated keys (e.g. multi-checkbox) collapse into an array.
435
+ const existing = values[key];
436
+ values[key] = Array.isArray(existing) ? [...existing, str] : [existing, str];
437
+ }
438
+ else {
439
+ values[key] = str;
440
+ }
441
+ }
442
+ return { values, files, meta: { attribution, elapsedMs, pageUrl, captchaToken } };
443
+ }
444
+ // JSON body.
445
+ const body = (await request.json().catch(() => null));
446
+ if (!body)
447
+ return null;
448
+ const values = body.fields && typeof body.fields === 'object' && !Array.isArray(body.fields)
449
+ ? body.fields
450
+ : null;
451
+ if (!values)
452
+ return null;
453
+ return {
454
+ values,
455
+ files: [],
456
+ meta: {
457
+ attribution: safeJsonObject(body.attribution),
458
+ elapsedMs: typeof body.elapsedMs === 'number' && Number.isFinite(body.elapsedMs)
459
+ ? body.elapsedMs
460
+ : null,
461
+ pageUrl: typeof body.pageUrl === 'string' ? body.pageUrl : null,
462
+ captchaToken: typeof body.captchaToken === 'string' ? body.captchaToken : '',
463
+ },
464
+ };
465
+ }
466
+ function safeJsonObject(value) {
467
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
468
+ return value;
469
+ }
470
+ if (typeof value === 'string' && value.trim()) {
471
+ try {
472
+ const parsed = JSON.parse(value);
473
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
474
+ return parsed;
475
+ }
476
+ }
477
+ catch {
478
+ /* ignore malformed attribution blob */
479
+ }
480
+ }
481
+ return null;
482
+ }
483
+ /**
484
+ * Shared public-submission handler used by both the slug-based public route and
485
+ * the legacy by-id route. Enforces per-IP+form rate limiting, embed-origin
486
+ * allowlisting, optional CAPTCHA, file validation, then runs the core
487
+ * submission pipeline. Always responds with CORS headers so embedded forms can
488
+ * read the result.
489
+ */
490
+ async function submitToForm(request, form) {
491
+ const allowedDomains = form.embedSettings?.allowedDomains;
492
+ const cors = formCorsHeaders(request, allowedDomains);
493
+ const ip = getClientIp(request);
494
+ const rlKey = isResolvedIp(ip) ? `formsubmit:${ip}:${form.id}` : `formsubmit:_unknown:${form.id}`;
495
+ if (!(await checkRateLimitAsync(formLimiterGlobal, rlKey))) {
496
+ return jsonWithHeaders({ error: 'Too many submissions. Please try again later.' }, 429, cors);
497
+ }
498
+ // Hard server-side origin check when an embed allowlist is configured —
499
+ // browser CORS alone does not prevent a non-browser client from posting.
500
+ if (allowedDomains && allowedDomains.length > 0) {
501
+ const origin = request.headers.get('origin');
502
+ if (origin && !isOriginAllowed(origin, allowedDomains)) {
503
+ return jsonWithHeaders({ error: 'This form cannot be submitted from this origin.' }, 403, cors);
504
+ }
505
+ }
506
+ const parsed = await parseSubmissionRequest(request);
507
+ if (!parsed) {
508
+ return jsonWithHeaders({ error: 'Invalid or missing submission body.' }, 400, cors);
509
+ }
510
+ const captchaConfig = getCaptchaConfig();
511
+ if (captchaConfig.provider !== 'none') {
512
+ const result = await verifyCaptcha(parsed.meta.captchaToken, captchaConfig, isResolvedIp(ip) ? ip : undefined);
513
+ if (!result.success) {
514
+ return jsonWithHeaders({ error: 'CAPTCHA verification failed. Please try again.' }, 403, cors);
515
+ }
516
+ }
517
+ if (parsed.files.length > 0) {
518
+ const fileCfg = getActuateConfig()?.forms?.fileUpload;
519
+ if (fileCfg?.enabled === false) {
520
+ return jsonWithHeaders({ error: 'File uploads are not enabled for this site.' }, 400, cors);
521
+ }
522
+ const { fields } = await resolveSubmissionFields(form);
523
+ const fileCheck = validateSubmissionFiles(fields, parsed.files, {
524
+ maxSizeBytes: fileCfg?.maxSizeMB ? fileCfg.maxSizeMB * 1024 * 1024 : DEFAULT_MAX_FILE_BYTES,
525
+ allowedMimeTypes: fileCfg?.allowedMimeTypes ?? FORM_UPLOAD_ALLOWED_MIME_TYPES,
526
+ });
527
+ if (!fileCheck.ok) {
528
+ return jsonWithHeaders({ error: 'File validation failed.', code: 'INVALID_FILES', details: fileCheck.errors }, 422, cors);
529
+ }
530
+ }
531
+ const ctx = {
532
+ values: parsed.values,
533
+ files: parsed.files,
534
+ ip,
535
+ userAgent: request.headers.get('user-agent'),
536
+ referrer: request.headers.get('referer'),
537
+ origin: request.headers.get('origin'),
538
+ pageUrl: parsed.meta.pageUrl,
539
+ attribution: parsed.meta.attribution,
540
+ elapsedMs: parsed.meta.elapsedMs,
541
+ };
542
+ try {
543
+ const result = await processSubmission(form, ctx);
544
+ return jsonWithHeaders({
545
+ data: {
546
+ success: true,
547
+ submissionId: result.submissionId,
548
+ entryNumber: result.entryNumber,
549
+ confirmation: result.confirmation,
550
+ },
551
+ }, 201, cors);
552
+ }
553
+ catch (err) {
554
+ if (err instanceof SubmissionValidationError) {
555
+ return jsonWithHeaders({ error: 'Validation failed.', code: 'VALIDATION_ERROR', details: err.fieldErrors }, 422, cors);
556
+ }
557
+ return internalError(err);
558
+ }
559
+ }
284
560
  function normalizeRedirect(redirect) {
285
561
  return {
286
562
  ...redirect,
@@ -3992,35 +4268,17 @@ export function registerCMSRoutes(router) {
3992
4268
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
3993
4269
  if (roleErr)
3994
4270
  return roleErr;
3995
- const d = db();
3996
- const forms = await d.document.findMany({
3997
- where: { collection: 'forms', deletedAt: null },
3998
- orderBy: { createdAt: 'desc' },
3999
- });
4000
- // Single grouped count is far cheaper than N+1 count() per form once
4001
- // the dashboard grows past a handful of forms. Fall back to per-form
4002
- // .count() when the consumer's Prisma client doesn't expose groupBy
4003
- // (older schemas, custom adapters).
4004
- let submissionCounts = new Map();
4005
- if (hasModel(d, 'formSubmission') && forms.length > 0) {
4006
- const ids = forms.map((f) => f.id);
4007
- if (typeof d.formSubmission.groupBy === 'function') {
4008
- const grouped = await d.formSubmission.groupBy({
4009
- by: ['formId'],
4010
- where: { formId: { in: ids } },
4011
- _count: { _all: true },
4012
- });
4013
- submissionCounts = new Map(grouped.map((g) => [g.formId, g._count._all]));
4014
- }
4015
- else {
4016
- for (const id of ids) {
4017
- const count = await d.formSubmission.count({ where: { formId: id } });
4018
- submissionCounts.set(id, count);
4019
- }
4020
- }
4021
- }
4022
- const normalized = forms.map((form) => normalizeFormDocument(form, submissionCounts.get(form.id) ?? 0));
4023
- return json({ data: normalized });
4271
+ const url = new URL(request.url);
4272
+ const { forms, total, page, pageSize } = await listFormsService(parseFormsQuery(url));
4273
+ // Response is a superset of the typed FormDefinition with legacy aliases
4274
+ // (`submissions`, `fieldCount`) so older admin views keep working while
4275
+ // PR3 migrates them to the typed shape.
4276
+ const data = forms.map((f) => ({
4277
+ ...f,
4278
+ fieldCount: f.fields?.length ?? 0,
4279
+ submissions: f.totalEntries ?? 0,
4280
+ }));
4281
+ return json({ data, total, page, pageSize });
4024
4282
  }
4025
4283
  catch (err) {
4026
4284
  return internalError(err);
@@ -4061,71 +4319,407 @@ export function registerCMSRoutes(router) {
4061
4319
  return internalError(err);
4062
4320
  }
4063
4321
  });
4064
- router.post('/forms/:id/submit', async (request, params) => {
4322
+ // Public, unauthenticated form schema what an embedded form needs to render.
4323
+ // CSRF-exempt and CORS-enabled (see handler-factory + formCorsHeaders).
4324
+ router.options('/public/forms/:slug', async (request, params) => {
4325
+ const form = await getFormBySlugService(params.slug).catch(() => null);
4326
+ return new Response(null, {
4327
+ status: 204,
4328
+ headers: formCorsHeaders(request, form?.embedSettings?.allowedDomains),
4329
+ });
4330
+ });
4331
+ router.get('/public/forms/:slug', async (request, params) => {
4065
4332
  try {
4066
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
4067
- if (!(await checkRateLimitAsync(formLimiterGlobal, `form:${clientIp}`))) {
4068
- return errorResponse('Too many submissions. Please try again later.', 429);
4333
+ const form = await getFormBySlugService(params.slug);
4334
+ if (!form || form.status !== 'active') {
4335
+ return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
4069
4336
  }
4070
- const formId = params.id;
4071
- const form = await db().document.findFirst({
4072
- where: { id: formId, collection: 'forms', deletedAt: null },
4337
+ const cors = formCorsHeaders(request, form.embedSettings?.allowedDomains);
4338
+ const { fields, versionId } = await resolveSubmissionFields(form);
4339
+ const captcha = getCaptchaConfig();
4340
+ const publicForm = buildPublicForm(form, fields, versionId, {
4341
+ captcha: { provider: captcha.provider, siteKey: captcha.siteKey },
4073
4342
  });
4074
- if (!form) {
4075
- return errorResponse('Form not found', 404);
4343
+ return jsonWithHeaders({ data: publicForm }, 200, cors);
4344
+ }
4345
+ catch (err) {
4346
+ return internalError(err);
4347
+ }
4348
+ });
4349
+ router.options('/public/forms/:slug/submit', async (request, params) => {
4350
+ const form = await getFormBySlugService(params.slug).catch(() => null);
4351
+ return new Response(null, {
4352
+ status: 204,
4353
+ headers: formCorsHeaders(request, form?.embedSettings?.allowedDomains),
4354
+ });
4355
+ });
4356
+ router.post('/public/forms/:slug/submit', async (request, params) => {
4357
+ try {
4358
+ const form = await getFormBySlugService(params.slug);
4359
+ if (!form || form.status !== 'active') {
4360
+ return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
4076
4361
  }
4077
- const formData = (form.data ?? {});
4078
- const body = (await request.json());
4079
- if (!body.fields || typeof body.fields !== 'object') {
4080
- return errorResponse('Missing or invalid "fields" in request body', 400);
4362
+ return submitToForm(request, form);
4363
+ }
4364
+ catch (err) {
4365
+ return internalError(err);
4366
+ }
4367
+ });
4368
+ // Legacy by-id submission endpoint — same pipeline, kept for existing embeds.
4369
+ router.post('/forms/:id/submit', async (request, params) => {
4370
+ try {
4371
+ const form = await getFormByIdService(params.id);
4372
+ if (!form || form.status !== 'active') {
4373
+ return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
4081
4374
  }
4082
- const captchaConfig = getCaptchaConfig();
4083
- if (captchaConfig.provider !== 'none') {
4084
- const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
4085
- const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, ip);
4086
- if (!captchaResult.success) {
4087
- return errorResponse('CAPTCHA verification failed. Please try again.', 403);
4088
- }
4375
+ return submitToForm(request, form);
4376
+ }
4377
+ catch (err) {
4378
+ return internalError(err);
4379
+ }
4380
+ });
4381
+ // ---------------------------------------------------------------------------
4382
+ // Forms management routes (admin) — services in ../forms/*.
4383
+ //
4384
+ // Route order matters: the router matches the first registered pattern, and
4385
+ // `:id` is a greedy `[^/]+`. All static `/forms/<word>` GETs are registered
4386
+ // before `/forms/:id` so e.g. `/forms/entries` is not captured as an id.
4387
+ // ---------------------------------------------------------------------------
4388
+ router.get('/forms/stats', async (request) => {
4389
+ try {
4390
+ const auth = await requireAuth(request);
4391
+ if (auth.error)
4392
+ return auth.error;
4393
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4394
+ if (roleErr)
4395
+ return roleErr;
4396
+ return json({ data: await getFormStatsService() });
4397
+ }
4398
+ catch (err) {
4399
+ return internalError(err);
4400
+ }
4401
+ });
4402
+ router.get('/forms/sidebar-counts', async (request) => {
4403
+ try {
4404
+ const auth = await requireAuth(request);
4405
+ if (auth.error)
4406
+ return auth.error;
4407
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4408
+ if (roleErr)
4409
+ return roleErr;
4410
+ return json({ data: await getFormsSidebarCountsService() });
4411
+ }
4412
+ catch (err) {
4413
+ return internalError(err);
4414
+ }
4415
+ });
4416
+ router.get('/forms/entries/counts', async (request) => {
4417
+ try {
4418
+ const auth = await requireAuth(request);
4419
+ if (auth.error)
4420
+ return auth.error;
4421
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4422
+ if (roleErr)
4423
+ return roleErr;
4424
+ const counts = await getEntryCountsService(parseEntriesQuery(new URL(request.url)));
4425
+ return json({ data: counts });
4426
+ }
4427
+ catch (err) {
4428
+ return internalError(err);
4429
+ }
4430
+ });
4431
+ router.get('/forms/entries/:entryId', async (request, params) => {
4432
+ try {
4433
+ const auth = await requireAuth(request);
4434
+ if (auth.error)
4435
+ return auth.error;
4436
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4437
+ if (roleErr)
4438
+ return roleErr;
4439
+ const result = await getEntryWithSchemaService(params.entryId);
4440
+ if (!result)
4441
+ return errorResponse('Entry not found', 404);
4442
+ return json({ data: result });
4443
+ }
4444
+ catch (err) {
4445
+ return internalError(err);
4446
+ }
4447
+ });
4448
+ router.patch('/forms/entries/:entryId', async (request, params) => {
4449
+ try {
4450
+ const auth = await requireAuth(request);
4451
+ if (auth.error)
4452
+ return auth.error;
4453
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4454
+ if (roleErr)
4455
+ return roleErr;
4456
+ const body = (await request.json().catch(() => ({})));
4457
+ const ctx = buildActionContext(auth.session, db());
4458
+ const updated = await updateEntryService(ctx, params.entryId, body);
4459
+ if (!updated)
4460
+ return errorResponse('Entry not found', 404);
4461
+ return json({ data: updated });
4462
+ }
4463
+ catch (err) {
4464
+ return internalError(err);
4465
+ }
4466
+ });
4467
+ router.delete('/forms/entries/:entryId', async (request, params) => {
4468
+ try {
4469
+ const auth = await requireAuth(request);
4470
+ if (auth.error)
4471
+ return auth.error;
4472
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4473
+ if (roleErr)
4474
+ return roleErr;
4475
+ const ctx = buildActionContext(auth.session, db());
4476
+ await deleteEntryService(ctx, params.entryId);
4477
+ return json({ data: { success: true } });
4478
+ }
4479
+ catch (err) {
4480
+ return internalError(err);
4481
+ }
4482
+ });
4483
+ router.post('/forms/entries/bulk', async (request) => {
4484
+ try {
4485
+ const auth = await requireAuth(request);
4486
+ if (auth.error)
4487
+ return auth.error;
4488
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4489
+ if (roleErr)
4490
+ return roleErr;
4491
+ const body = (await request.json().catch(() => ({})));
4492
+ if (!Array.isArray(body.ids) || body.ids.length === 0) {
4493
+ return errorResponse('"ids" must be a non-empty array', 400);
4089
4494
  }
4090
- const submission = await db().formSubmission.create({
4091
- data: {
4092
- formId,
4093
- data: body.fields,
4094
- attribution: body.attribution ?? null,
4095
- submittedAt: new Date(),
4096
- },
4097
- });
4098
- (async () => {
4099
- try {
4100
- const config = getActuateConfig();
4101
- const hooks = (config?._pluginHooks ?? []);
4102
- const formHooks = hooks.filter((h) => h?.event === 'afterCreate:form-submissions');
4103
- for (const hook of formHooks) {
4104
- await hook.handler({ formId, data: body.fields });
4105
- }
4106
- }
4107
- catch (hookErr) {
4108
- console.error('[cms] Form hook error:', hookErr);
4109
- }
4110
- })();
4111
- const confirmation = formData.confirmation ?? {
4112
- type: 'message',
4113
- message: 'Thank you! Your submission has been received.',
4114
- };
4115
- const analytics = formData.analytics ?? null;
4116
- return json({
4117
- data: {
4118
- success: true,
4119
- submissionId: submission.id,
4120
- confirmation,
4121
- analytics,
4122
- },
4123
- }, 201);
4495
+ const ctx = buildActionContext(auth.session, db());
4496
+ const count = await bulkUpdateEntriesService(ctx, body.ids, body.patch ?? {});
4497
+ return json({ data: { updated: count } });
4498
+ }
4499
+ catch (err) {
4500
+ return internalError(err);
4501
+ }
4502
+ });
4503
+ router.get('/forms/entries', async (request) => {
4504
+ try {
4505
+ const auth = await requireAuth(request);
4506
+ if (auth.error)
4507
+ return auth.error;
4508
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4509
+ if (roleErr)
4510
+ return roleErr;
4511
+ const result = await listEntriesService(parseEntriesQuery(new URL(request.url)));
4512
+ return json({ data: result });
4513
+ }
4514
+ catch (err) {
4515
+ return internalError(err);
4516
+ }
4517
+ });
4518
+ router.post('/forms', async (request) => {
4519
+ try {
4520
+ const auth = await requireAuth(request);
4521
+ if (auth.error)
4522
+ return auth.error;
4523
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4524
+ if (roleErr)
4525
+ return roleErr;
4526
+ const body = (await request.json().catch(() => null));
4527
+ if (!body || typeof body.name !== 'string' || !body.name.trim()) {
4528
+ return errorResponse('"name" is required', 400);
4529
+ }
4530
+ const ctx = buildActionContext(auth.session, db());
4531
+ const form = await createFormService(ctx, body);
4532
+ return json({ data: form }, 201);
4533
+ }
4534
+ catch (err) {
4535
+ return mapFormError(err) ?? internalError(err);
4536
+ }
4537
+ });
4538
+ router.get('/forms/:id', async (request, params) => {
4539
+ try {
4540
+ const auth = await requireAuth(request);
4541
+ if (auth.error)
4542
+ return auth.error;
4543
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4544
+ if (roleErr)
4545
+ return roleErr;
4546
+ const form = await getFormByIdService(params.id);
4547
+ if (!form)
4548
+ return errorResponse('Form not found', 404);
4549
+ return json({ data: form });
4124
4550
  }
4125
4551
  catch (err) {
4126
4552
  return internalError(err);
4127
4553
  }
4128
4554
  });
4555
+ router.patch('/forms/:id', async (request, params) => {
4556
+ try {
4557
+ const auth = await requireAuth(request);
4558
+ if (auth.error)
4559
+ return auth.error;
4560
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4561
+ if (roleErr)
4562
+ return roleErr;
4563
+ const body = (await request.json().catch(() => ({})));
4564
+ const ctx = buildActionContext(auth.session, db());
4565
+ const form = await updateFormService(ctx, params.id, body);
4566
+ return json({ data: form });
4567
+ }
4568
+ catch (err) {
4569
+ return mapFormError(err) ?? internalError(err);
4570
+ }
4571
+ });
4572
+ router.delete('/forms/:id', async (request, params) => {
4573
+ try {
4574
+ const auth = await requireAuth(request);
4575
+ if (auth.error)
4576
+ return auth.error;
4577
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4578
+ if (roleErr)
4579
+ return roleErr;
4580
+ const url = new URL(request.url);
4581
+ const force = url.searchParams.get('force') === 'true';
4582
+ const ctx = buildActionContext(auth.session, db());
4583
+ await deleteFormService(ctx, params.id, { force });
4584
+ return json({ data: { success: true } });
4585
+ }
4586
+ catch (err) {
4587
+ return mapFormError(err) ?? internalError(err);
4588
+ }
4589
+ });
4590
+ router.post('/forms/:id/duplicate', async (request, params) => {
4591
+ try {
4592
+ const auth = await requireAuth(request);
4593
+ if (auth.error)
4594
+ return auth.error;
4595
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4596
+ if (roleErr)
4597
+ return roleErr;
4598
+ const ctx = buildActionContext(auth.session, db());
4599
+ const form = await duplicateFormService(ctx, params.id);
4600
+ return json({ data: form }, 201);
4601
+ }
4602
+ catch (err) {
4603
+ return mapFormError(err) ?? internalError(err);
4604
+ }
4605
+ });
4606
+ router.get('/forms/:id/schema', async (request, params) => {
4607
+ try {
4608
+ const auth = await requireAuth(request);
4609
+ if (auth.error)
4610
+ return auth.error;
4611
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4612
+ if (roleErr)
4613
+ return roleErr;
4614
+ const schema = await getFormSchemaService(params.id);
4615
+ return json({ data: schema });
4616
+ }
4617
+ catch (err) {
4618
+ return mapFormError(err) ?? internalError(err);
4619
+ }
4620
+ });
4621
+ router.put('/forms/:id/schema', async (request, params) => {
4622
+ try {
4623
+ const auth = await requireAuth(request);
4624
+ if (auth.error)
4625
+ return auth.error;
4626
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4627
+ if (roleErr)
4628
+ return roleErr;
4629
+ const body = (await request.json().catch(() => ({})));
4630
+ if (!Array.isArray(body.fields)) {
4631
+ return errorResponse('"fields" must be an array', 400);
4632
+ }
4633
+ const ctx = buildActionContext(auth.session, db());
4634
+ const result = await saveFormSchemaService(ctx, params.id, body.fields, body.notes);
4635
+ return json({ data: result });
4636
+ }
4637
+ catch (err) {
4638
+ return mapFormError(err) ?? internalError(err);
4639
+ }
4640
+ });
4641
+ router.get('/forms/:id/notifications', async (request, params) => {
4642
+ try {
4643
+ const auth = await requireAuth(request);
4644
+ if (auth.error)
4645
+ return auth.error;
4646
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4647
+ if (roleErr)
4648
+ return roleErr;
4649
+ const settings = await getFormNotificationSettingsService(params.id);
4650
+ return json({ data: settings });
4651
+ }
4652
+ catch (err) {
4653
+ return mapFormError(err) ?? internalError(err);
4654
+ }
4655
+ });
4656
+ router.put('/forms/:id/notifications', async (request, params) => {
4657
+ try {
4658
+ const auth = await requireAuth(request);
4659
+ if (auth.error)
4660
+ return auth.error;
4661
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4662
+ if (roleErr)
4663
+ return roleErr;
4664
+ const body = (await request.json().catch(() => ({})));
4665
+ const ctx = buildActionContext(auth.session, db());
4666
+ const settings = await updateFormNotificationSettingsService(ctx, params.id, body);
4667
+ return json({ data: settings });
4668
+ }
4669
+ catch (err) {
4670
+ return mapFormError(err) ?? internalError(err);
4671
+ }
4672
+ });
4673
+ // Lightweight notify toggle used by the forms table switch.
4674
+ router.put('/forms/:id/notify', async (request, params) => {
4675
+ try {
4676
+ const auth = await requireAuth(request);
4677
+ if (auth.error)
4678
+ return auth.error;
4679
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4680
+ if (roleErr)
4681
+ return roleErr;
4682
+ const body = (await request.json().catch(() => ({})));
4683
+ const ctx = buildActionContext(auth.session, db());
4684
+ const settings = await setFormNotifyEnabledService(ctx, params.id, Boolean(body.enabled));
4685
+ return json({ data: { notifyEnabled: settings.enabled } });
4686
+ }
4687
+ catch (err) {
4688
+ return mapFormError(err) ?? internalError(err);
4689
+ }
4690
+ });
4691
+ router.get('/forms/:id/embed', async (request, params) => {
4692
+ try {
4693
+ const auth = await requireAuth(request);
4694
+ if (auth.error)
4695
+ return auth.error;
4696
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4697
+ if (roleErr)
4698
+ return roleErr;
4699
+ const settings = await getFormEmbedSettingsService(params.id);
4700
+ return json({ data: settings });
4701
+ }
4702
+ catch (err) {
4703
+ return mapFormError(err) ?? internalError(err);
4704
+ }
4705
+ });
4706
+ router.put('/forms/:id/embed', async (request, params) => {
4707
+ try {
4708
+ const auth = await requireAuth(request);
4709
+ if (auth.error)
4710
+ return auth.error;
4711
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4712
+ if (roleErr)
4713
+ return roleErr;
4714
+ const body = (await request.json().catch(() => ({})));
4715
+ const ctx = buildActionContext(auth.session, db());
4716
+ const settings = await updateFormEmbedSettingsService(ctx, params.id, body);
4717
+ return json({ data: settings });
4718
+ }
4719
+ catch (err) {
4720
+ return mapFormError(err) ?? internalError(err);
4721
+ }
4722
+ });
4129
4723
  // ---------------------------------------------------------------------------
4130
4724
  // Redirects routes
4131
4725
  // ---------------------------------------------------------------------------
@@ -4438,8 +5032,10 @@ export function registerCMSRoutes(router) {
4438
5032
  }
4439
5033
  const { suggestRedirectTarget } = await import('../redirects/suggest.js');
4440
5034
  // Candidate set: published page/post URLs.
4441
- const { gatherAuditEntities } = await import('../seo/audit-runner.js');
4442
- const entities = await gatherAuditEntities(db());
5035
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
5036
+ const entities = await gatherAuditEntities(db(), {
5037
+ collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
5038
+ });
4443
5039
  const candidates = entities.map((e) => ({ url: e.url, title: e.title }));
4444
5040
  const unresolved = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
4445
5041
  let created = 0;
@@ -5046,60 +5642,13 @@ export function registerCMSRoutes(router) {
5046
5642
  if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
5047
5643
  return errorResponse('Too many scans. Please try again later.', 429);
5048
5644
  }
5049
- const documents = await db().document.findMany({
5050
- where: { status: 'PUBLISHED', deletedAt: null },
5051
- select: {
5052
- id: true,
5053
- title: true,
5054
- slug: true,
5055
- collection: true,
5056
- data: true,
5057
- plainText: true,
5058
- },
5059
- orderBy: { updatedAt: 'desc' },
5060
- take: 5000,
5061
- });
5062
- const issues = [];
5063
- for (const doc of documents) {
5064
- const data = (doc.data ?? {});
5065
- const problems = [];
5066
- if (!data.metaTitle && !data.seoTitle)
5067
- problems.push('Missing meta title');
5068
- if (!data.metaDescription && !data.seoDescription)
5069
- problems.push('Missing meta description');
5070
- if (!data.canonical)
5071
- problems.push('No canonical URL set');
5072
- if (!data.schemaType)
5073
- problems.push('No Schema.org type');
5074
- const plainText = (doc.plainText ?? '');
5075
- if (plainText.length > 0 && plainText.length < 300)
5076
- problems.push('Content is too short (< 300 characters)');
5077
- const content = typeof data.body === 'string'
5078
- ? data.body
5079
- : typeof data.content === 'string'
5080
- ? data.content
5081
- : '';
5082
- if (content) {
5083
- const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
5084
- const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
5085
- if (missingAlt > 0)
5086
- problems.push(`${missingAlt} image(s) missing alt text`);
5087
- if (!content.includes('<h1') && !content.includes('<h1>'))
5088
- problems.push('No H1 heading found in content');
5089
- }
5090
- if (problems.length > 0) {
5091
- issues.push({
5092
- documentId: doc.id,
5093
- title: doc.title ?? 'Untitled',
5094
- slug: doc.slug ?? '',
5095
- problems,
5096
- });
5097
- }
5098
- }
5099
- const total = documents.length;
5100
- const pagesWithIssues = issues.length;
5101
- const totalProblems = issues.reduce((sum, i) => sum + i.problems.length, 0);
5102
- return json({ data: { total, pagesWithIssues, totalProblems, issues } });
5645
+ // Scope the scan to configured front-end content collections so structural
5646
+ // (navigation/footer) and benchmark documents are never reported. Shares
5647
+ // the exact scan logic with the cron path via processSeoScan.
5648
+ const { frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
5649
+ const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
5650
+ const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
5651
+ return json({ data: result });
5103
5652
  }
5104
5653
  catch (err) {
5105
5654
  return internalError(err);
@@ -5152,9 +5701,10 @@ export function registerCMSRoutes(router) {
5152
5701
  };
5153
5702
  }
5154
5703
  async function buildSeoContentRecords() {
5155
- const { gatherAuditEntities } = await import('../seo/audit-runner.js');
5704
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
5156
5705
  const { calculatePageSeoScore } = await import('../seo/score.js');
5157
- const entities = await gatherAuditEntities(db());
5706
+ const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
5707
+ const entities = await gatherAuditEntities(db(), { collectionTypes });
5158
5708
  return entities.map((e) => {
5159
5709
  const score = calculatePageSeoScore(e);
5160
5710
  return {
@@ -5232,8 +5782,12 @@ export function registerCMSRoutes(router) {
5232
5782
  }
5233
5783
  const body = (await request.json().catch(() => ({})));
5234
5784
  const scope = body?.scope ?? 'full';
5235
- const { runAndPersistAudit } = await import('../seo/audit-runner.js');
5236
- const run = await runAndPersistAudit(db(), { scope, startedById: auth.session.userId });
5785
+ const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
5786
+ const run = await runAndPersistAudit(db(), {
5787
+ scope,
5788
+ startedById: auth.session.userId,
5789
+ collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
5790
+ });
5237
5791
  try {
5238
5792
  await logEvent({
5239
5793
  event: 'settings_changed',
@@ -5827,6 +6381,13 @@ export function registerCMSRoutes(router) {
5827
6381
  const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
5828
6382
  const out = [];
5829
6383
  for (const [slug, col] of Object.entries(resolved.collections ?? {})) {
6384
+ // Only publicly-rendered content types belong in the sitemap. Structural
6385
+ // / utility collections (navigation menus, footers, tags, templates) have
6386
+ // no `type` and must never produce indexable sitemap URLs.
6387
+ if (col.type !== 'page' && col.type !== 'post')
6388
+ continue;
6389
+ if (col.admin?.hidden)
6390
+ continue;
5830
6391
  if (excluded.has(slug))
5831
6392
  continue;
5832
6393
  if (col.seo?.excludeFromSitemap)
@@ -5907,6 +6468,12 @@ export function registerCMSRoutes(router) {
5907
6468
  const collection = cfg?.collections?.[rawSlug];
5908
6469
  if (!collection)
5909
6470
  return errorResponse('Collection not found', 404);
6471
+ // Only publicly-rendered page/post collections are sitemap-eligible —
6472
+ // structural/utility collections (menus, tags, templates) are not.
6473
+ if (collection.type !== 'page' && collection.type !== 'post')
6474
+ return errorResponse('Collection excluded from sitemap', 404);
6475
+ if (collection.admin?.hidden)
6476
+ return errorResponse('Collection excluded from sitemap', 404);
5910
6477
  if (collection.seo?.excludeFromSitemap)
5911
6478
  return errorResponse('Collection excluded from sitemap', 404);
5912
6479
  const base = siteUrlFromRequest(request, cfg);
@@ -8023,7 +8590,9 @@ export function registerCMSRoutes(router) {
8023
8590
  if (!isAuthorizedCronRequest(request.headers.get('authorization'))) {
8024
8591
  return errorResponse('Unauthorized', 401);
8025
8592
  }
8026
- const result = await processSeoScan(db());
8593
+ const { frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
8594
+ const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
8595
+ const result = await processSeoScan(db(), { allowedCollections });
8027
8596
  return json({ data: result });
8028
8597
  }
8029
8598
  catch (err) {