drupal-mcp-connector 2.25.0 → 3.0.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.
- package/.agents/commands/drupal-audit-site-health.md +1 -1
- package/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
- package/.agents/commands/drupal-report-alias-coverage.md +4 -2
- package/.agents/commands/drupal-report-broken-embeds.md +4 -2
- package/.agents/commands/drupal-report-broken-links.md +4 -2
- package/.agents/commands/drupal-report-content-by-author.md +4 -2
- package/.agents/commands/drupal-report-duplicate-content.md +4 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-orphan-pages.md +4 -2
- package/.agents/commands/drupal-report-orphaned-references.md +4 -2
- package/.agents/commands/drupal-report-pii-exposure.md +4 -2
- package/.agents/commands/drupal-report-readability.md +4 -2
- package/.agents/commands/drupal-report-recently-published.md +1 -1
- package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
- package/.agents/commands/drupal-report-scheduled-content.md +4 -4
- package/.agents/commands/drupal-report-seo-audit.md +4 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
- package/.agents/commands/drupal-report-stale-content.md +4 -2
- package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
- package/.agents/commands/drupal-report-translation-coverage.md +4 -2
- package/.agents/commands/drupal-report-unpublished.md +1 -1
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
- package/.agents/commands/drupal-schedule-publish.md +5 -3
- package/.agents/commands/drupal-search-content.md +1 -1
- package/.agents/commands/drupal-search.md +1 -1
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +114 -0
- package/README.md +57 -1
- package/bin/drupal-mcp-doctor.js +16 -0
- package/bin/drupal-mcp-wizard.js +17 -0
- package/package.json +10 -5
- package/src/index.js +19 -19
- package/src/lib/backends/jsonapi.js +19 -7
- package/src/lib/dispatch.js +22 -0
- package/src/lib/doctor.js +645 -0
- package/src/lib/module-tools.js +33 -2
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +32 -32
- package/src/lib/principal.js +13 -5
- package/src/lib/reports-support.js +68 -0
- package/src/lib/security.js +42 -44
- package/src/lib/sentinel-draft.js +59 -3
- package/src/lib/server-tools.js +3 -3
- package/src/lib/transports.js +153 -0
- package/src/lib/version-skew.js +193 -0
- package/src/lib/wizard.js +902 -0
- package/src/lib/workflow-prompts.js +5 -5
- package/src/tools/audit-composite.js +21 -11
- package/src/tools/codegen.js +2 -2
- package/src/tools/config.js +11 -6
- package/src/tools/entities.js +1 -1
- package/src/tools/index.js +1 -1
- package/src/tools/media.js +2 -2
- package/src/tools/nodes.js +17 -9
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +109 -39
- package/src/tools/reports-extra.js +56 -32
- package/src/tools/reports-links.js +13 -13
- package/src/tools/reports.js +65 -42
- package/src/tools/revisions.js +2 -1
- package/src/tools/scheduler.js +142 -27
- package/src/tools/search.js +16 -12
- package/src/tools/translations.js +7 -1
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* scheduled-content state, body readability, orphan pages (no inbound internal
|
|
7
7
|
* links), accidental PII exposure, and structured-meta (SEO) coverage.
|
|
8
8
|
*
|
|
9
|
+
* Each report except scheduled content scans one node bundle named by `type`
|
|
10
|
+
* (there is no default bundle; #403). Scheduled content scans every bundle when
|
|
11
|
+
* `type` is omitted.
|
|
12
|
+
*
|
|
9
13
|
* Each handler asserts read access in-handler, samples via collectEntities, and
|
|
10
14
|
* flags `approximate: true` when the scan is sampling-bounded. Audits that need a
|
|
11
15
|
* field the site doesn't expose (moderation state, scheduler dates, metatag
|
|
@@ -17,6 +21,7 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
17
21
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
18
22
|
import {
|
|
19
23
|
collectEntities, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince,
|
|
24
|
+
requireContentType,
|
|
20
25
|
} from "../lib/reports-support.js";
|
|
21
26
|
import { bodyHtml, extractAnchors, classifyLink, normalizePath } from "../lib/audit-support.js";
|
|
22
27
|
import {
|
|
@@ -61,11 +66,11 @@ function scalar(entity, candidates) {
|
|
|
61
66
|
* @returns {Promise<object>} Duplicate-title groups.
|
|
62
67
|
*/
|
|
63
68
|
async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
|
|
69
|
+
const contentType = requireContentType(type, "drupal_report_duplicate_content");
|
|
64
70
|
const site = getSiteConfig(siteName);
|
|
65
71
|
const sec = resolveSecurityConfig(site);
|
|
66
|
-
assertReadAllowed(sec, "node",
|
|
72
|
+
assertReadAllowed(sec, "node", contentType);
|
|
67
73
|
const backend = await resolveBackend(site);
|
|
68
|
-
const contentType = type || "article";
|
|
69
74
|
const nodes = await collectEntities(
|
|
70
75
|
backend,
|
|
71
76
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -106,11 +111,11 @@ async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
106
111
|
* @returns {Promise<object>} Stuck-content findings.
|
|
107
112
|
*/
|
|
108
113
|
async function workflowBottlenecks({ site: siteName, type, days = 30, states, sampleSize = 200, langcode }) {
|
|
114
|
+
const contentType = requireContentType(type, "drupal_report_workflow_bottlenecks");
|
|
109
115
|
const site = getSiteConfig(siteName);
|
|
110
116
|
const sec = resolveSecurityConfig(site);
|
|
111
|
-
assertReadAllowed(sec, "node",
|
|
117
|
+
assertReadAllowed(sec, "node", contentType);
|
|
112
118
|
const backend = await resolveBackend(site);
|
|
113
|
-
const contentType = type || "article";
|
|
114
119
|
const targetStates = (states && states.length ? states : ["draft", "needs_review", "review"]).map((s) => s.toLowerCase());
|
|
115
120
|
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
116
121
|
|
|
@@ -200,11 +205,11 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
|
|
|
200
205
|
* @returns {Promise<object>} Coverage, or unavailable.
|
|
201
206
|
*/
|
|
202
207
|
async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
|
|
208
|
+
const contentType = requireContentType(type, "drupal_report_translation_coverage");
|
|
203
209
|
const site = getSiteConfig(siteName);
|
|
204
210
|
const sec = resolveSecurityConfig(site);
|
|
205
|
-
assertReadAllowed(sec, "node",
|
|
211
|
+
assertReadAllowed(sec, "node", contentType);
|
|
206
212
|
const backend = await resolveBackend(site);
|
|
207
|
-
const contentType = type || "article";
|
|
208
213
|
const nodes = await collectEntities(
|
|
209
214
|
backend,
|
|
210
215
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -283,19 +288,18 @@ async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
|
|
|
283
288
|
// ---------------------------------------------------------------------------
|
|
284
289
|
|
|
285
290
|
/**
|
|
286
|
-
*
|
|
287
|
-
* (date in the future) and overdue (date in the past but the action hasn't run).
|
|
288
|
-
* Gated when the scheduler fields aren't exposed.
|
|
291
|
+
* Scan one bundle for Scheduler dates.
|
|
289
292
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
293
|
+
* Whether Scheduler is exposed is decided from the attribute key, not its
|
|
294
|
+
* value: a node with `publish_on: null` has the field and nothing scheduled
|
|
295
|
+
* (#403). A bundle with no nodes cannot show either way, so it reports zero
|
|
296
|
+
* pending and overdue with `schedulerFields: "unknown"`.
|
|
297
|
+
* @param {object} backend Resolved backend.
|
|
298
|
+
* @param {string} contentType Node bundle.
|
|
299
|
+
* @param {number} sampleSize Max nodes to scan.
|
|
300
|
+
* @returns {Promise<object>} Per-bundle result.
|
|
292
301
|
*/
|
|
293
|
-
async function
|
|
294
|
-
const site = getSiteConfig(siteName);
|
|
295
|
-
const sec = resolveSecurityConfig(site);
|
|
296
|
-
assertReadAllowed(sec, "node", type);
|
|
297
|
-
const backend = await resolveBackend(site);
|
|
298
|
-
const contentType = type || "article";
|
|
302
|
+
async function scanScheduledBundle(backend, contentType, sampleSize) {
|
|
299
303
|
const nodes = await collectEntities(
|
|
300
304
|
backend,
|
|
301
305
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -308,9 +312,10 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
308
312
|
const overdue = [];
|
|
309
313
|
for (const n of nodes) {
|
|
310
314
|
for (const [field, action] of [["publish_on", "publish"], ["unpublish_on", "unpublish"]]) {
|
|
311
|
-
const
|
|
315
|
+
const { present, value } = fieldPresence(n, field);
|
|
316
|
+
if (present) sawField = true;
|
|
317
|
+
const raw = value && typeof value === "object" && "value" in value ? value.value : value;
|
|
312
318
|
if (raw === undefined || raw === null || raw === "" || raw === 0) continue;
|
|
313
|
-
sawField = true;
|
|
314
319
|
const ms = toMillis(raw);
|
|
315
320
|
if (ms === null) continue;
|
|
316
321
|
const rec = { id: n.id, title: n.title, action, when: new Date(ms).toISOString(), path: n.url };
|
|
@@ -319,11 +324,19 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
319
324
|
}
|
|
320
325
|
}
|
|
321
326
|
|
|
322
|
-
if (!sawField) {
|
|
323
|
-
return {
|
|
327
|
+
if (nodes.length && !sawField) {
|
|
328
|
+
return {
|
|
329
|
+
contentType,
|
|
330
|
+
gated: true,
|
|
331
|
+
schedulerFields: "absent",
|
|
332
|
+
reason: "No sampled node carries a publish_on or unpublish_on key: Scheduler is not enabled for this content type, or this account cannot view those fields.",
|
|
333
|
+
scanned: nodes.length,
|
|
334
|
+
approximate: nodes.length >= sampleSize,
|
|
335
|
+
};
|
|
324
336
|
}
|
|
325
337
|
return {
|
|
326
338
|
contentType,
|
|
339
|
+
schedulerFields: sawField ? "present" : "unknown",
|
|
327
340
|
scanned: nodes.length,
|
|
328
341
|
approximate: nodes.length >= sampleSize,
|
|
329
342
|
summary: { pending: pending.length, overdue: overdue.length },
|
|
@@ -331,6 +344,56 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
331
344
|
};
|
|
332
345
|
}
|
|
333
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Report content with Scheduler publish/unpublish dates set, split into pending
|
|
349
|
+
* (date in the future) and overdue (date in the past but the action hasn't run).
|
|
350
|
+
*
|
|
351
|
+
* With `type`, scans that bundle. Without it, scans every node bundle and
|
|
352
|
+
* returns a combined summary plus one row per bundle; a bundle that cannot be
|
|
353
|
+
* read is listed with its error rather than failing the whole report.
|
|
354
|
+
*
|
|
355
|
+
* @param {object} args - { site?, type?, sampleSize? }.
|
|
356
|
+
* @returns {Promise<object>} Pending and overdue scheduled items.
|
|
357
|
+
*/
|
|
358
|
+
async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
359
|
+
const site = getSiteConfig(siteName);
|
|
360
|
+
const sec = resolveSecurityConfig(site);
|
|
361
|
+
assertReadAllowed(sec, "node", type);
|
|
362
|
+
const backend = await resolveBackend(site);
|
|
363
|
+
if (type) return scanScheduledBundle(backend, type, sampleSize);
|
|
364
|
+
|
|
365
|
+
const bundles = (await backend.listContentTypes()).map((ct) => ct.id).filter(Boolean);
|
|
366
|
+
const byContentType = [];
|
|
367
|
+
const pending = [];
|
|
368
|
+
const overdue = [];
|
|
369
|
+
let approximate = false;
|
|
370
|
+
for (const bundle of bundles) {
|
|
371
|
+
let row;
|
|
372
|
+
try {
|
|
373
|
+
assertReadAllowed(sec, "node", bundle);
|
|
374
|
+
row = await scanScheduledBundle(backend, bundle, sampleSize);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
byContentType.push({ contentType: bundle, error: err?.message || String(err) });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
approximate = approximate || Boolean(row.approximate);
|
|
380
|
+
for (const rec of row.findings?.pending ?? []) pending.push({ ...rec, contentType: bundle });
|
|
381
|
+
for (const rec of row.findings?.overdue ?? []) overdue.push({ ...rec, contentType: bundle });
|
|
382
|
+
const summaryRow = { ...row };
|
|
383
|
+
delete summaryRow.findings;
|
|
384
|
+
byContentType.push(summaryRow);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
contentType: null,
|
|
388
|
+
scannedBundles: bundles.length,
|
|
389
|
+
scanned: byContentType.reduce((n, r) => n + (r.scanned || 0), 0),
|
|
390
|
+
approximate,
|
|
391
|
+
summary: { pending: pending.length, overdue: overdue.length },
|
|
392
|
+
byContentType,
|
|
393
|
+
findings: { pending, overdue },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
334
397
|
/**
|
|
335
398
|
* Coerce a scheduler date value (unix seconds, ms, or ISO string) to epoch ms.
|
|
336
399
|
* @param {*} raw Field value.
|
|
@@ -356,11 +419,11 @@ function toMillis(raw) {
|
|
|
356
419
|
* @returns {Promise<object>} Per-node scores and aggregate.
|
|
357
420
|
*/
|
|
358
421
|
async function readability({ site: siteName, type, sampleSize = 100, hardThreshold = 30 }) {
|
|
422
|
+
const contentType = requireContentType(type, "drupal_report_readability");
|
|
359
423
|
const site = getSiteConfig(siteName);
|
|
360
424
|
const sec = resolveSecurityConfig(site);
|
|
361
|
-
assertReadAllowed(sec, "node",
|
|
425
|
+
assertReadAllowed(sec, "node", contentType);
|
|
362
426
|
const backend = await resolveBackend(site);
|
|
363
|
-
const contentType = type || "article";
|
|
364
427
|
const nodes = await collectEntities(
|
|
365
428
|
backend,
|
|
366
429
|
{ entityType: "node", bundle: contentType, filters: [{ field: "status", op: "eq", value: true }] },
|
|
@@ -438,11 +501,11 @@ function countSyllables(word) {
|
|
|
438
501
|
* @returns {Promise<object>} Orphan pages.
|
|
439
502
|
*/
|
|
440
503
|
async function orphanPages({ site: siteName, type, sampleSize = 200 }) {
|
|
504
|
+
const contentType = requireContentType(type, "drupal_report_orphan_pages");
|
|
441
505
|
const site = getSiteConfig(siteName);
|
|
442
506
|
const sec = resolveSecurityConfig(site);
|
|
443
|
-
assertReadAllowed(sec, "node",
|
|
507
|
+
assertReadAllowed(sec, "node", contentType);
|
|
444
508
|
const backend = await resolveBackend(site);
|
|
445
|
-
const contentType = type || "article";
|
|
446
509
|
const baseUrl = site.baseUrl;
|
|
447
510
|
const nodes = await collectEntities(
|
|
448
511
|
backend,
|
|
@@ -491,11 +554,11 @@ const PII_PATTERNS = new Map([
|
|
|
491
554
|
* @returns {Promise<object>} PII findings, masked.
|
|
492
555
|
*/
|
|
493
556
|
async function piiExposure({ site: siteName, type, sampleSize = 100, kinds }) {
|
|
557
|
+
const contentType = requireContentType(type, "drupal_report_pii_exposure");
|
|
494
558
|
const site = getSiteConfig(siteName);
|
|
495
559
|
const sec = resolveSecurityConfig(site);
|
|
496
|
-
assertReadAllowed(sec, "node",
|
|
560
|
+
assertReadAllowed(sec, "node", contentType);
|
|
497
561
|
const backend = await resolveBackend(site);
|
|
498
|
-
const contentType = type || "article";
|
|
499
562
|
const active = (kinds && kinds.length ? kinds : [...PII_PATTERNS.keys()]).filter((k) => PII_PATTERNS.has(k));
|
|
500
563
|
const nodes = await collectEntities(
|
|
501
564
|
backend,
|
|
@@ -568,11 +631,11 @@ const DEFAULT_META_FIELDS = ["field_meta_tags", "field_metatag", "metatag", "fie
|
|
|
568
631
|
* @returns {Promise<object>} Per-field coverage and nodes missing all meta.
|
|
569
632
|
*/
|
|
570
633
|
async function seoMetaCoverage({ site: siteName, type, fields, sampleSize = 100 }) {
|
|
634
|
+
const contentType = requireContentType(type, "drupal_report_seo_meta_coverage");
|
|
571
635
|
const site = getSiteConfig(siteName);
|
|
572
636
|
const sec = resolveSecurityConfig(site);
|
|
573
|
-
assertReadAllowed(sec, "node",
|
|
637
|
+
assertReadAllowed(sec, "node", contentType);
|
|
574
638
|
const backend = await resolveBackend(site);
|
|
575
|
-
const contentType = type || "article";
|
|
576
639
|
const named = requestedFieldNames(fields);
|
|
577
640
|
const requested = named.length > 0;
|
|
578
641
|
const metaFields = requested ? named : DEFAULT_META_FIELDS;
|
|
@@ -636,9 +699,10 @@ export const definitions = [
|
|
|
636
699
|
description: "Find duplicate / near-duplicate titles within a content type (normalized title grouping). Surfaces accidental re-publishing and content cannibalization.",
|
|
637
700
|
inputSchema: {
|
|
638
701
|
type: "object",
|
|
702
|
+
required: ["type"],
|
|
639
703
|
properties: {
|
|
640
704
|
site: { type: "string" },
|
|
641
|
-
type: { type: "string", description: "Content type (
|
|
705
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
642
706
|
sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
|
|
643
707
|
},
|
|
644
708
|
},
|
|
@@ -648,9 +712,10 @@ export const definitions = [
|
|
|
648
712
|
description: "Find content stuck in a non-published moderation state (draft/needs_review) beyond N days — editorial bottlenecks. Reads moderation_state; gated when content_moderation isn't exposed.",
|
|
649
713
|
inputSchema: {
|
|
650
714
|
type: "object",
|
|
715
|
+
required: ["type"],
|
|
651
716
|
properties: {
|
|
652
717
|
site: { type: "string" },
|
|
653
|
-
type: { type: "string", description: "Content type (
|
|
718
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
654
719
|
days: { type: "number", default: 30, description: "Days-in-state threshold" },
|
|
655
720
|
states: { type: "array", items: { type: "string" }, description: "Moderation states to treat as bottlenecks" },
|
|
656
721
|
langcode: { type: "string", description: "Limit to this translation (Sentinel inventory). Omit for the default-language field on each node." },
|
|
@@ -663,22 +728,23 @@ export const definitions = [
|
|
|
663
728
|
description: "Per-node translation coverage from Sentinel's inventory (missing non-default language, outdated core flag, language counts). Without Sentinel the report is unavailable — JSON:API only shows the default language, so a histogram would be misleading.",
|
|
664
729
|
inputSchema: {
|
|
665
730
|
type: "object",
|
|
731
|
+
required: ["type"],
|
|
666
732
|
properties: {
|
|
667
733
|
site: { type: "string" },
|
|
668
|
-
type: { type: "string", description: "Content type (
|
|
734
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
669
735
|
sampleSize: { type: "number", default: 100, description: "Max nodes to inventory" },
|
|
670
736
|
},
|
|
671
737
|
},
|
|
672
738
|
},
|
|
673
739
|
{
|
|
674
740
|
name: "drupal_report_scheduled_content",
|
|
675
|
-
description: "Report content with Scheduler publish/unpublish dates set, split into pending (future) and overdue (past, action not run).
|
|
741
|
+
description: "Report content with Scheduler publish/unpublish dates set, split into pending (future) and overdue (past, action not run). Omit type to scan every node bundle in one call (one row per bundle in byContentType). A bundle is gated only when sampled nodes carry no publish_on/unpublish_on key at all; fields that exist but are empty report pending 0 / overdue 0. A bundle with no nodes reports zero counts with schedulerFields 'unknown'.",
|
|
676
742
|
inputSchema: {
|
|
677
743
|
type: "object",
|
|
678
744
|
properties: {
|
|
679
745
|
site: { type: "string" },
|
|
680
|
-
type: { type: "string", description: "Content type
|
|
681
|
-
sampleSize: { type: "number", default: 200 },
|
|
746
|
+
type: { type: "string", description: "Content type machine name. Omit to scan every node bundle." },
|
|
747
|
+
sampleSize: { type: "number", default: 200, description: "Max nodes to scan per content type" },
|
|
682
748
|
},
|
|
683
749
|
},
|
|
684
750
|
},
|
|
@@ -687,9 +753,10 @@ export const definitions = [
|
|
|
687
753
|
description: "Score body readability (Flesch Reading Ease) for a content type and flag hard-to-read content and structural issues (no H2 subheadings, multiple H1s).",
|
|
688
754
|
inputSchema: {
|
|
689
755
|
type: "object",
|
|
756
|
+
required: ["type"],
|
|
690
757
|
properties: {
|
|
691
758
|
site: { type: "string" },
|
|
692
|
-
type: { type: "string", description: "Content type (
|
|
759
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
693
760
|
sampleSize: { type: "number", default: 100 },
|
|
694
761
|
hardThreshold: { type: "number", default: 30, description: "Flag content scoring below this" },
|
|
695
762
|
},
|
|
@@ -700,9 +767,10 @@ export const definitions = [
|
|
|
700
767
|
description: "Find published pages with no inbound internal links from other sampled pages — content islands. Best-effort over the sampled set.",
|
|
701
768
|
inputSchema: {
|
|
702
769
|
type: "object",
|
|
770
|
+
required: ["type"],
|
|
703
771
|
properties: {
|
|
704
772
|
site: { type: "string" },
|
|
705
|
-
type: { type: "string", description: "Content type (
|
|
773
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
706
774
|
sampleSize: { type: "number", default: 200 },
|
|
707
775
|
},
|
|
708
776
|
},
|
|
@@ -712,9 +780,10 @@ export const definitions = [
|
|
|
712
780
|
description: "Scan published body content for accidentally exposed PII (emails, US SSNs, phone numbers). Matched values are masked in the output so the report itself doesn't leak data.",
|
|
713
781
|
inputSchema: {
|
|
714
782
|
type: "object",
|
|
783
|
+
required: ["type"],
|
|
715
784
|
properties: {
|
|
716
785
|
site: { type: "string" },
|
|
717
|
-
type: { type: "string", description: "Content type (
|
|
786
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
718
787
|
sampleSize: { type: "number", default: 100 },
|
|
719
788
|
kinds: { type: "array", items: { type: "string", enum: ["email", "ssn", "phone"] }, description: "PII kinds to scan (default: all)" },
|
|
720
789
|
},
|
|
@@ -725,9 +794,10 @@ export const definitions = [
|
|
|
725
794
|
description: "Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage. Reads scalar and entity-reference fields. A field absent from every sampled node has `present: false` and `coverage: null` (unknown, not 0), and is listed in `notVisible` when you named it. When none of the checked fields is visible, no node is flagged and `nodesMissingAllMeta` is null: JSON:API leaves out a field this account may not view.",
|
|
726
795
|
inputSchema: {
|
|
727
796
|
type: "object",
|
|
797
|
+
required: ["type"],
|
|
728
798
|
properties: {
|
|
729
799
|
site: { type: "string" },
|
|
730
|
-
type: { type: "string", description: "Content type (
|
|
800
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
731
801
|
fields: { type: "array", items: { type: "string" }, description: "Meta field machine names to check" },
|
|
732
802
|
sampleSize: { type: "number", default: 100 },
|
|
733
803
|
},
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { getSiteConfig } from "../lib/config.js";
|
|
14
14
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
15
15
|
import { resolveSecurityConfig, assertReadAllowed, assertEntityTypeAllowed } from "../lib/security.js";
|
|
16
|
-
import { collectEntities, fieldPresence } from "../lib/reports-support.js";
|
|
16
|
+
import { collectEntities, fieldPresence, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
|
|
17
17
|
import { httpStatusOf } from "../lib/error-status.js";
|
|
18
18
|
|
|
19
19
|
/** Author base fields that only ever point at `user`. */
|
|
@@ -100,11 +100,14 @@ function refsOf(rel) {
|
|
|
100
100
|
// ---------------------------------------------------------------------------
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Unpublished / draft content
|
|
103
|
+
* Unpublished / draft content. With `type`, reads that bundle. Without it,
|
|
104
|
+
* reads every node bundle the connector may read, merges by `changed`
|
|
105
|
+
* (newest first) and keeps `limit` findings; a bundle that fails to load is
|
|
106
|
+
* listed in `bundleErrors` (#403).
|
|
104
107
|
*
|
|
105
|
-
* @param {object} args - { site?, type?, limit? }.
|
|
106
|
-
* @returns {Promise<{contentType: string, approximate: boolean,
|
|
107
|
-
* totalUnpublished: number, findings: object[]}>} Matching unpublished nodes.
|
|
108
|
+
* @param {object} args - { site?, type?, limit? }.
|
|
109
|
+
* @returns {Promise<{contentType: ?string, approximate: boolean,
|
|
110
|
+
* totalUnpublished: number, findings: object[], bundleErrors?: object[]}>} Matching unpublished nodes.
|
|
108
111
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
109
112
|
*/
|
|
110
113
|
async function unpublished({ site: siteName, type, limit = 50 }) {
|
|
@@ -112,24 +115,45 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
|
|
|
112
115
|
const sec = resolveSecurityConfig(site);
|
|
113
116
|
assertReadAllowed(sec, "node", type);
|
|
114
117
|
const backend = await resolveBackend(site);
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
const bundles = type ? [type] : await readableNodeBundles(backend, sec);
|
|
119
|
+
const findings = [];
|
|
120
|
+
const bundleErrors = [];
|
|
121
|
+
let approximate = false;
|
|
122
|
+
let totalUnpublished = 0;
|
|
123
|
+
for (const bundle of bundles) {
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = await backend.listEntities({
|
|
127
|
+
entityType: "node", bundle,
|
|
128
|
+
filters: [{ field: "status", op: "eq", value: false }],
|
|
129
|
+
sort: [{ field: "changed", dir: "desc" }],
|
|
130
|
+
page: { limit },
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (type) throw err;
|
|
134
|
+
bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
approximate = approximate || (res.approximate ?? false);
|
|
138
|
+
totalUnpublished += res.page?.total ?? res.entities.length;
|
|
139
|
+
for (const n of res.entities) {
|
|
140
|
+
findings.push({
|
|
141
|
+
id: n.id,
|
|
142
|
+
title: n.title,
|
|
143
|
+
status: "unpublished",
|
|
144
|
+
changed: n.changed,
|
|
145
|
+
path: n.url,
|
|
146
|
+
...(type ? {} : { contentType: bundle }),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!type) findings.sort((x, y) => String(y.changed ?? "").localeCompare(String(x.changed ?? "")));
|
|
122
151
|
return {
|
|
123
|
-
contentType,
|
|
124
|
-
approximate
|
|
125
|
-
totalUnpublished
|
|
126
|
-
findings:
|
|
127
|
-
|
|
128
|
-
title: n.title,
|
|
129
|
-
status: "unpublished",
|
|
130
|
-
changed: n.changed,
|
|
131
|
-
path: n.url,
|
|
132
|
-
})),
|
|
152
|
+
contentType: type || null,
|
|
153
|
+
approximate,
|
|
154
|
+
totalUnpublished,
|
|
155
|
+
findings: findings.slice(0, limit),
|
|
156
|
+
...(bundleErrors.length ? { bundleErrors } : {}),
|
|
133
157
|
};
|
|
134
158
|
}
|
|
135
159
|
|
|
@@ -153,12 +177,12 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
|
|
|
153
177
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
154
178
|
*/
|
|
155
179
|
async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
|
|
180
|
+
const contentType = requireContentType(type, "drupal_report_missing_field");
|
|
156
181
|
const site = getSiteConfig(siteName);
|
|
157
182
|
const sec = resolveSecurityConfig(site);
|
|
158
|
-
assertReadAllowed(sec, "node",
|
|
183
|
+
assertReadAllowed(sec, "node", contentType);
|
|
159
184
|
if (!field) throw new Error("missingField requires a field machine name.");
|
|
160
185
|
const backend = await resolveBackend(site);
|
|
161
|
-
const contentType = type || "article";
|
|
162
186
|
const entities = await collectEntities(
|
|
163
187
|
backend,
|
|
164
188
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -213,16 +237,16 @@ async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
|
|
|
213
237
|
* orphan; 401/403 and connector policy denials are unverifiable (#205).
|
|
214
238
|
* Sampling-bounded, so `approximate` is set when the entity scan is capped.
|
|
215
239
|
*
|
|
216
|
-
* @param {object} args - { site?, type
|
|
240
|
+
* @param {object} args - { site?, type, sampleSize? }. `type` is required.
|
|
217
241
|
* @returns {Promise<object>} Orphaned-reference findings plus scan metadata.
|
|
218
242
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
219
243
|
*/
|
|
220
244
|
async function orphanedReferences({ site: siteName, type, sampleSize = 50 }) {
|
|
245
|
+
const contentType = requireContentType(type, "drupal_report_orphaned_references");
|
|
221
246
|
const site = getSiteConfig(siteName);
|
|
222
247
|
const sec = resolveSecurityConfig(site);
|
|
223
|
-
assertReadAllowed(sec, "node",
|
|
248
|
+
assertReadAllowed(sec, "node", contentType);
|
|
224
249
|
const backend = await resolveBackend(site);
|
|
225
|
-
const contentType = type || "article";
|
|
226
250
|
const entities = await collectEntities(
|
|
227
251
|
backend,
|
|
228
252
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -331,7 +355,7 @@ export const definitions = [
|
|
|
331
355
|
type: "object",
|
|
332
356
|
properties: {
|
|
333
357
|
site: { type: "string" },
|
|
334
|
-
type: { type: "string", description: "Content type machine name (
|
|
358
|
+
type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
|
|
335
359
|
limit: { type: "number", default: 50, description: "Max unpublished nodes to return" },
|
|
336
360
|
},
|
|
337
361
|
},
|
|
@@ -340,10 +364,10 @@ export const definitions = [
|
|
|
340
364
|
name: "drupal_report_missing_field",
|
|
341
365
|
description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped. An absent field is not an empty one: JSON:API leaves out a field this account may not view. When the field is absent from every sampled entity the result is `notVisible: true` with no findings (the field may be denied to this account, not exist on the bundle, or be misspelled) instead of every entity counted as missing. Otherwise each finding has `reason` 'empty' (key present, no value) or 'absent' (key missing, possibly access-denied).",
|
|
342
366
|
inputSchema: {
|
|
343
|
-
type: "object", required: ["field"],
|
|
367
|
+
type: "object", required: ["field", "type"],
|
|
344
368
|
properties: {
|
|
345
369
|
site: { type: "string" },
|
|
346
|
-
type: { type: "string", description: "Content type machine name (
|
|
370
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
347
371
|
field: { type: "string", description: "Field machine name to check for emptiness, e.g. 'field_meta_description', 'field_image'" },
|
|
348
372
|
sampleSize: { type: "number", default: 100, description: "Max entities to scan" },
|
|
349
373
|
},
|
|
@@ -353,10 +377,10 @@ export const definitions = [
|
|
|
353
377
|
name: "drupal_report_orphaned_references",
|
|
354
378
|
description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded.",
|
|
355
379
|
inputSchema: {
|
|
356
|
-
type: "object",
|
|
380
|
+
type: "object", required: ["type"],
|
|
357
381
|
properties: {
|
|
358
382
|
site: { type: "string" },
|
|
359
|
-
type: { type: "string", description: "Content type machine name
|
|
383
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
360
384
|
sampleSize: { type: "number", default: 50, description: "Max entities to scan for broken references" },
|
|
361
385
|
},
|
|
362
386
|
},
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { getSiteConfig } from "../lib/config.js";
|
|
19
19
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
20
20
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
21
|
-
import { collectEntities, gatedReport, fieldValue } from "../lib/reports-support.js";
|
|
21
|
+
import { collectEntities, gatedReport, fieldValue, requireContentType } from "../lib/reports-support.js";
|
|
22
22
|
import { runPrivileged } from "../lib/audit-sources.js";
|
|
23
23
|
import { sshDrush, parseDrush } from "./drush.js";
|
|
24
24
|
import {
|
|
@@ -258,11 +258,11 @@ async function redirectHealth({ site: siteName, limit = 1000 }) {
|
|
|
258
258
|
* @returns {Promise<object>} Link inventory plus optional live results.
|
|
259
259
|
*/
|
|
260
260
|
async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive = false, includeExternal = false }) {
|
|
261
|
+
const contentType = requireContentType(type, "drupal_report_broken_links");
|
|
261
262
|
const site = getSiteConfig(siteName);
|
|
262
263
|
const sec = resolveSecurityConfig(site);
|
|
263
|
-
assertReadAllowed(sec, "node",
|
|
264
|
+
assertReadAllowed(sec, "node", contentType);
|
|
264
265
|
const backend = await resolveBackend(site);
|
|
265
|
-
const contentType = type || "article";
|
|
266
266
|
const baseUrl = site.baseUrl;
|
|
267
267
|
const baseHost = hostOf(baseUrl);
|
|
268
268
|
|
|
@@ -345,11 +345,11 @@ async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive =
|
|
|
345
345
|
* @returns {Promise<object>} Alias-coverage findings.
|
|
346
346
|
*/
|
|
347
347
|
async function aliasCoverage({ site: siteName, type, sampleSize = 200 }) {
|
|
348
|
+
const contentType = requireContentType(type, "drupal_report_alias_coverage");
|
|
348
349
|
const site = getSiteConfig(siteName);
|
|
349
350
|
const sec = resolveSecurityConfig(site);
|
|
350
|
-
assertReadAllowed(sec, "node",
|
|
351
|
+
assertReadAllowed(sec, "node", contentType);
|
|
351
352
|
const backend = await resolveBackend(site);
|
|
352
|
-
const contentType = type || "article";
|
|
353
353
|
|
|
354
354
|
const nodes = await collectEntities(
|
|
355
355
|
backend,
|
|
@@ -463,11 +463,11 @@ async function menuIntegrity({ site: siteName, limit = 1000 }) {
|
|
|
463
463
|
* @returns {Promise<object>} Embed inventory and any malformed embeds.
|
|
464
464
|
*/
|
|
465
465
|
async function brokenEmbeds({ site: siteName, type, sampleSize = 100 }) {
|
|
466
|
+
const contentType = requireContentType(type, "drupal_report_broken_embeds");
|
|
466
467
|
const site = getSiteConfig(siteName);
|
|
467
468
|
const sec = resolveSecurityConfig(site);
|
|
468
|
-
assertReadAllowed(sec, "node",
|
|
469
|
+
assertReadAllowed(sec, "node", contentType);
|
|
469
470
|
const backend = await resolveBackend(site);
|
|
470
|
-
const contentType = type || "article";
|
|
471
471
|
|
|
472
472
|
const entities = await collectEntities(
|
|
473
473
|
backend,
|
|
@@ -538,10 +538,10 @@ export const definitions = [
|
|
|
538
538
|
name: "drupal_report_broken_links",
|
|
539
539
|
description: "Inventory links in published body content (internal/external/images), aggregate external hosts, and flag malformed hrefs. With checkLive:true, verifies links via bounded, SSRF-guarded outbound HTTP (internal always; external only if includeExternal and host-allowlisted). No network egress unless checkLive is set.",
|
|
540
540
|
inputSchema: {
|
|
541
|
-
type: "object",
|
|
541
|
+
type: "object", required: ["type"],
|
|
542
542
|
properties: {
|
|
543
543
|
site: { type: "string" },
|
|
544
|
-
type: { type: "string", description: "Content type (
|
|
544
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
545
545
|
sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
546
546
|
checkLive: { type: "boolean", default: false, description: "Perform live HTTP checks (off by default)" },
|
|
547
547
|
includeExternal: { type: "boolean", default: false, description: "When checkLive, also check allowlisted external hosts" },
|
|
@@ -552,10 +552,10 @@ export const definitions = [
|
|
|
552
552
|
name: "drupal_report_alias_coverage",
|
|
553
553
|
description: "URL-alias coverage for a content type: nodes whose canonical URL is still /node/N (no alias / pathauto gap), plus conflicting aliases (one alias mapped to multiple system paths) when the path_alias entity is exposed.",
|
|
554
554
|
inputSchema: {
|
|
555
|
-
type: "object",
|
|
555
|
+
type: "object", required: ["type"],
|
|
556
556
|
properties: {
|
|
557
557
|
site: { type: "string" },
|
|
558
|
-
type: { type: "string", description: "Content type (
|
|
558
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
559
559
|
sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
|
|
560
560
|
},
|
|
561
561
|
},
|
|
@@ -575,10 +575,10 @@ export const definitions = [
|
|
|
575
575
|
name: "drupal_report_broken_embeds",
|
|
576
576
|
description: "Scan published body content for embedded entities (media/entity embeds) and report usage by type, flagging embeds with a missing/malformed data-entity-uuid. Full target-existence verification is gated on a future server-tool.",
|
|
577
577
|
inputSchema: {
|
|
578
|
-
type: "object",
|
|
578
|
+
type: "object", required: ["type"],
|
|
579
579
|
properties: {
|
|
580
580
|
site: { type: "string" },
|
|
581
|
-
type: { type: "string", description: "Content type (
|
|
581
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
582
582
|
sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
583
583
|
},
|
|
584
584
|
},
|