drupal-mcp-connector 1.5.0 → 1.6.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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Guarded live link checker — opt-in outbound HTTP for the broken-links audit.
3
+ *
4
+ * Single responsibility: given a set of URLs, perform bounded liveness checks
5
+ * (HEAD, falling back to GET) and report each URL's status. Outbound HTTP is a
6
+ * privileged capability, so this module is the one place network egress is
7
+ * allowed during an audit, and only when the caller explicitly opts in.
8
+ *
9
+ * Safeguards (see checkLinks):
10
+ * - SSRF guard: only http(s); refuse loopback/private/link-local/metadata
11
+ * hosts and IP literals in private ranges.
12
+ * - Allowlist: external hosts are skipped unless listed in
13
+ * `allowedHosts`; same-origin (internal) hosts are always allowed.
14
+ * - Caps: bounded concurrency, per-request timeout, and a hard ceiling on the
15
+ * number of URLs checked (results flag `truncated` when the ceiling is hit).
16
+ */
17
+
18
+ import nodeFetch from "node-fetch";
19
+
20
+ /** Default per-request timeout (ms). */
21
+ export const DEFAULT_TIMEOUT_MS = 5000;
22
+ /** Default number of concurrent in-flight checks. */
23
+ export const DEFAULT_CONCURRENCY = 5;
24
+ /** Default hard ceiling on URLs checked in one call. */
25
+ export const DEFAULT_MAX_LINKS = 200;
26
+
27
+ /**
28
+ * Hostnames that must never be probed regardless of allowlist — loopback and
29
+ * cloud metadata endpoints.
30
+ */
31
+ const BLOCKED_HOSTNAMES = new Set([
32
+ "localhost",
33
+ "metadata.google.internal",
34
+ ]);
35
+
36
+ /**
37
+ * Decide whether a URL is safe to probe: http(s) only, and not pointed at a
38
+ * loopback, private, link-local, unique-local, or metadata address. Hostnames
39
+ * that are not IP literals are allowed past the IP-range check (DNS is not
40
+ * resolved here); the allowlist in checkLinks is the second gate for those.
41
+ *
42
+ * @param {string} url Absolute URL to validate.
43
+ * @returns {{safe: boolean, reason: ?string, host: ?string}} Verdict.
44
+ */
45
+ export function isSafeUrl(url) {
46
+ let parsed;
47
+ try { parsed = new URL(url); }
48
+ catch { return { safe: false, reason: "unparseable URL", host: null }; }
49
+
50
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
51
+ return { safe: false, reason: `unsupported protocol ${parsed.protocol}`, host: parsed.host };
52
+ }
53
+
54
+ // URL.hostname returns IPv6 literals wrapped in brackets; strip them so the
55
+ // range checks below see a bare address.
56
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
57
+ if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
58
+ return { safe: false, reason: "loopback host", host: parsed.host };
59
+ }
60
+ if (isPrivateIp(hostname)) {
61
+ return { safe: false, reason: "private or link-local address", host: parsed.host };
62
+ }
63
+ return { safe: true, reason: null, host: parsed.host };
64
+ }
65
+
66
+ /**
67
+ * Whether a hostname is an IP literal in a loopback/private/link-local/ULA range.
68
+ * Non-IP hostnames return false (resolution happens at fetch time, not here).
69
+ * @param {string} hostname Lowercased hostname from a URL.
70
+ * @returns {boolean} True when the literal IP is in a blocked range.
71
+ */
72
+ function isPrivateIp(hostname) {
73
+ // IPv6 literals arrive bracket-stripped from URL.hostname.
74
+ if (hostname.includes(":")) {
75
+ if (hostname === "::1") return true; // loopback
76
+ if (hostname.startsWith("fe80")) return true; // link-local
77
+ if (hostname.startsWith("fc") || hostname.startsWith("fd")) return true; // ULA
78
+ if (hostname.startsWith("::ffff:")) return isPrivateIp(hostname.slice(7)); // mapped v4
79
+ return false;
80
+ }
81
+ const m = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
82
+ if (!m) return false;
83
+ const [a, b] = [Number(m[1]), Number(m[2])];
84
+ if (a === 127) return true; // 127.0.0.0/8 loopback
85
+ if (a === 10) return true; // 10.0.0.0/8
86
+ if (a === 0) return true; // 0.0.0.0/8
87
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + metadata
88
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
89
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Check a list of URLs for liveness with bounded concurrency.
95
+ *
96
+ * @param {string[]} urls URLs to check (duplicates are de-duped).
97
+ * @param {object} [opts]
98
+ * @param {string[]} [opts.allowedHosts] Hosts allowed for external probes. A
99
+ * same-origin host (see opts.internalHost) is always allowed.
100
+ * @param {?string} [opts.internalHost] The site's own host; never needs listing.
101
+ * @param {number} [opts.maxConcurrency] In-flight cap (default 5).
102
+ * @param {number} [opts.timeoutMs] Per-request timeout (default 5000).
103
+ * @param {number} [opts.maxLinks] Hard ceiling on URLs checked (default 200).
104
+ * @param {Function} [opts.fetchImpl] Injected fetch (for tests); defaults to node-fetch.
105
+ * @returns {Promise<{checked: number, truncated: boolean, results: Array<{
106
+ * url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}>}>}
107
+ */
108
+ export async function checkLinks(urls, opts = {}) {
109
+ const {
110
+ allowedHosts = [],
111
+ internalHost = null,
112
+ maxConcurrency = DEFAULT_CONCURRENCY,
113
+ timeoutMs = DEFAULT_TIMEOUT_MS,
114
+ maxLinks = DEFAULT_MAX_LINKS,
115
+ fetchImpl = nodeFetch,
116
+ } = opts;
117
+
118
+ const allow = new Set(allowedHosts.map((h) => String(h).toLowerCase()));
119
+ if (internalHost) allow.add(String(internalHost).toLowerCase());
120
+
121
+ const unique = [...new Set(urls)];
122
+ const truncated = unique.length > maxLinks;
123
+ const queue = unique.slice(0, maxLinks);
124
+
125
+ const results = [];
126
+ let cursor = 0;
127
+ /**
128
+ * Worker: pull URLs off the shared queue until drained.
129
+ * @returns {Promise<void>}
130
+ */
131
+ async function worker() {
132
+ while (cursor < queue.length) {
133
+ const url = queue[cursor++];
134
+ results.push(await checkOne(url, { allow, timeoutMs, fetchImpl }));
135
+ }
136
+ }
137
+ const workers = Array.from({ length: Math.max(1, Math.min(maxConcurrency, queue.length)) }, worker);
138
+ await Promise.all(workers);
139
+
140
+ return { checked: results.length, truncated, results };
141
+ }
142
+
143
+ /**
144
+ * Check a single URL, applying the SSRF guard and host allowlist before any
145
+ * network call. Safe failures (blocked/not-allowlisted) are returned as
146
+ * `skipped`, not errors.
147
+ * @param {string} url URL to check.
148
+ * @param {{allow: Set<string>, timeoutMs: number, fetchImpl: Function}} ctx Shared context.
149
+ * @returns {Promise<{url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}>}
150
+ */
151
+ async function checkOne(url, { allow, timeoutMs, fetchImpl }) {
152
+ const guard = isSafeUrl(url);
153
+ if (!guard.safe) {
154
+ return { url, ok: false, status: null, skipped: true, reason: guard.reason };
155
+ }
156
+ if (!allow.has(guard.host.toLowerCase())) {
157
+ return { url, ok: false, status: null, skipped: true, reason: "host not in allowlist" };
158
+ }
159
+
160
+ // HEAD first; some servers reject HEAD (405/501) so retry once with GET.
161
+ const head = await timedFetch(url, "HEAD", { timeoutMs, fetchImpl });
162
+ if (head.status && head.status !== 405 && head.status !== 501) {
163
+ return finalize(url, head);
164
+ }
165
+ const get = await timedFetch(url, "GET", { timeoutMs, fetchImpl });
166
+ return finalize(url, get);
167
+ }
168
+
169
+ /**
170
+ * Build the public result record from a fetch outcome. Treats 2xx/3xx as ok.
171
+ * @param {string} url URL checked.
172
+ * @param {{status: ?number, error: ?string}} res Fetch outcome.
173
+ * @returns {{url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}}
174
+ */
175
+ function finalize(url, res) {
176
+ if (res.error) return { url, ok: false, status: null, skipped: false, reason: res.error };
177
+ const ok = res.status >= 200 && res.status < 400;
178
+ return { url, ok, status: res.status, skipped: false, reason: ok ? null : `HTTP ${res.status}` };
179
+ }
180
+
181
+ /**
182
+ * Perform a single fetch with an abort-based timeout. Never throws — network and
183
+ * timeout failures are returned as `{ status: null, error }`.
184
+ * @param {string} url URL to fetch.
185
+ * @param {"HEAD"|"GET"} method HTTP method.
186
+ * @param {{timeoutMs: number, fetchImpl: Function}} ctx Timeout + fetch impl.
187
+ * @returns {Promise<{status: ?number, error: ?string}>}
188
+ */
189
+ async function timedFetch(url, method, { timeoutMs, fetchImpl }) {
190
+ const controller = new AbortController();
191
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
192
+ try {
193
+ const res = await fetchImpl(url, { method, redirect: "follow", signal: controller.signal });
194
+ return { status: res.status, error: null };
195
+ } catch (err) {
196
+ const reason = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : (err?.message || "request failed");
197
+ return { status: null, error: reason };
198
+ } finally {
199
+ clearTimeout(timer);
200
+ }
201
+ }
@@ -309,6 +309,23 @@ export async function callServerTool(site, toolName, args = {}) {
309
309
  }
310
310
  }
311
311
 
312
+ /**
313
+ * Extract the structured data a server tool returned, for callers (the audit
314
+ * tools) that need to inspect the payload rather than relay it. Prefers the MCP
315
+ * `structuredContent` field; otherwise parses the joined text content as JSON,
316
+ * falling back to the raw text when it isn't JSON.
317
+ * @param {object} result MCP tools/call result (as returned by callServerTool).
318
+ * @returns {*} Parsed structured data, raw text, or null when empty.
319
+ */
320
+ export function toolResultData(result) {
321
+ if (!result) return null;
322
+ if (result.structuredContent !== undefined) return result.structuredContent;
323
+ const text = extractTextContent(result);
324
+ if (!text) return null;
325
+ try { return JSON.parse(text); }
326
+ catch { return text; }
327
+ }
328
+
312
329
  /**
313
330
  * Pull the concatenated text from an MCP tool result's content array.
314
331
  * @param {object} result MCP tools/call result.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Tool group: Composite site-health audit.
3
+ *
4
+ * `drupal_audit_site_health` runs a configurable battery of the individual audit
5
+ * tools (content + links + config) and rolls their results into one scored
6
+ * dashboard. Each section runs independently: a section that throws or returns a
7
+ * gated/unavailable payload is recorded as such and does not abort the others, so
8
+ * the dashboard is always returned. The roll-up grade reflects only the sections
9
+ * that produced findings.
10
+ *
11
+ * This module composes the other tool groups' handlers rather than re-querying
12
+ * the backend, so its behavior always matches the standalone tools.
13
+ */
14
+
15
+ import { getSiteConfig } from "../lib/config.js";
16
+ import * as reports from "./reports.js";
17
+ import * as reportsLinks from "./reports-links.js";
18
+ import * as reportsConfig from "./reports-config.js";
19
+ import * as reportsContent from "./reports-content.js";
20
+
21
+ /**
22
+ * The audit battery. Each section names its group, the handler to run, the args
23
+ * to pass (derived from the composite's options), and a scorer that extracts an
24
+ * `{ issues, high }` signal from the section's result.
25
+ */
26
+ const SECTIONS = [
27
+ // Content
28
+ { key: "content_summary", group: "content", run: (o) => reports.handlers.drupal_report_content_summary({ site: o.site }), score: () => ({ issues: 0, high: 0 }) },
29
+ { key: "stale_content", group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
30
+ { key: "seo_audit", group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
31
+ { key: "accessibility", group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
32
+ { key: "duplicate_content", group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
33
+ { key: "readability", group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
34
+ { key: "pii_exposure", group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
35
+ // Links
36
+ { key: "broken_links", group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
37
+ { key: "redirect_health", group: "links", run: (o) => reportsLinks.handlers.drupal_report_redirect_health({ site: o.site }), score: (r) => ({ issues: (r.summary?.duplicateSources || 0) + (r.summary?.chains || 0) + (r.summary?.loops || 0), high: r.summary?.loops || 0 }) },
38
+ { key: "alias_coverage", group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
39
+ { key: "log_404", group: "links", run: (o) => reportsLinks.handlers.drupal_report_404_log({ site: o.site }), score: (r) => ({ issues: (r.findings || []).length, high: 0 }) },
40
+ // Config
41
+ { key: "config_best_practices", group: "config", run: (o) => reportsConfig.handlers.drupal_audit_config_best_practices({ site: o.site }), score: (r) => ({ issues: (r.counts?.high || 0) + (r.counts?.medium || 0) + (r.counts?.low || 0), high: r.counts?.high || 0 }) },
42
+ { key: "module_audit", group: "config", run: (o) => reportsConfig.handlers.drupal_report_module_audit({ site: o.site }), score: (r) => ({ issues: (r.summary?.devModulesEnabled || 0) + (r.summary?.securityAdvisories || 0), high: r.summary?.securityAdvisories || 0 }) },
43
+ { key: "permission_audit", group: "config", run: (o) => reportsConfig.handlers.drupal_report_permission_audit({ site: o.site }), score: (r) => ({ issues: (r.findings || []).length, high: r.counts?.high || 0 }) },
44
+ ];
45
+
46
+ const SECTION_KEYS = SECTIONS.map((s) => s.key);
47
+
48
+ /**
49
+ * Sum the `count` values out of an seo/accessibility audit's `issues` map.
50
+ * @param {object} issues The audit's keyed issues object.
51
+ * @returns {number} Total issue count.
52
+ */
53
+ function sumIssueCounts(issues) {
54
+ if (!issues || typeof issues !== "object") return 0;
55
+ return Object.values(issues).reduce((sum, v) => sum + (v?.count || 0), 0);
56
+ }
57
+
58
+ /**
59
+ * Whether a section result is a gated/unavailable payload.
60
+ * @param {object} r A section result.
61
+ * @returns {boolean}
62
+ */
63
+ function isGated(r) {
64
+ return Boolean(r && (r.unavailable === true || r.gated === true));
65
+ }
66
+
67
+ /**
68
+ * Convert aggregate high/issue counts into a letter grade.
69
+ * @param {number} high Total high-severity findings.
70
+ * @param {number} issues Total findings.
71
+ * @returns {"A"|"B"|"C"|"D"|"F"} Roll-up grade.
72
+ */
73
+ function grade(high, issues) {
74
+ if (high === 0 && issues === 0) return "A";
75
+ if (high === 0 && issues <= 5) return "B";
76
+ if (high <= 2) return "C";
77
+ if (high <= 5) return "D";
78
+ return "F";
79
+ }
80
+
81
+ /**
82
+ * Run a battery of audits and roll the results into a scored dashboard.
83
+ *
84
+ * @param {object} args - { site?, type?, sampleSize?, sections? }.
85
+ * @returns {Promise<object>} The dashboard: per-section status + a roll-up grade.
86
+ */
87
+ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, sections }) {
88
+ const site = getSiteConfig(siteName);
89
+ const selected = sections && sections.length
90
+ ? SECTIONS.filter((s) => sections.includes(s.key))
91
+ : SECTIONS;
92
+ const opts = { site: site._name, type, sampleSize };
93
+
94
+ const results = [];
95
+ let totalHigh = 0;
96
+ let totalIssues = 0;
97
+ for (const section of selected) {
98
+ try {
99
+ const result = await section.run(opts);
100
+ if (isGated(result)) {
101
+ results.push({ key: section.key, group: section.group, status: "unavailable", reason: result.reason || result.attempts?.join("; ") || null });
102
+ continue;
103
+ }
104
+ const { issues, high } = section.score(result);
105
+ totalHigh += high;
106
+ totalIssues += issues;
107
+ results.push({ key: section.key, group: section.group, status: "ok", issues, high, result });
108
+ } catch (err) {
109
+ results.push({ key: section.key, group: section.group, status: "error", error: err?.message || String(err) });
110
+ }
111
+ }
112
+
113
+ const byStatus = (s) => results.filter((r) => r.status === s).length;
114
+ return {
115
+ site: site._name,
116
+ type,
117
+ grade: grade(totalHigh, totalIssues),
118
+ summary: {
119
+ sectionsRun: results.length,
120
+ ok: byStatus("ok"),
121
+ unavailable: byStatus("unavailable"),
122
+ errored: byStatus("error"),
123
+ totalHigh,
124
+ totalIssues,
125
+ },
126
+ sections: results,
127
+ };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Definitions & handlers
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export const definitions = [
135
+ {
136
+ name: "drupal_audit_site_health",
137
+ description: "Composite site-health dashboard: runs a battery of content, link, and configuration audits and rolls them into one scored report with a letter grade. Each section degrades independently — gated/errored sections are recorded, not fatal. Read-only.",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {
141
+ site: { type: "string" },
142
+ type: { type: "string", description: "Primary content type for content/link sections (default: article)" },
143
+ sampleSize: { type: "number", default: 50, description: "Per-section scan cap (kept small for a fast roll-up)" },
144
+ sections: { type: "array", items: { type: "string", enum: SECTION_KEYS }, description: `Subset of sections to run (default: all). Available: ${SECTION_KEYS.join(", ")}` },
145
+ },
146
+ },
147
+ },
148
+ ];
149
+
150
+ export const handlers = {
151
+ drupal_audit_site_health: siteHealth,
152
+ };
@@ -93,8 +93,12 @@ function resolveKeyPath(rawPath) {
93
93
  *
94
94
  * Security: args are individually shell-escaped via sanitizeSshArg().
95
95
  * No raw user input is ever interpolated directly into the command string.
96
+ *
97
+ * Exported so the audit tool groups can reuse the hardened bridge for their
98
+ * drush fallback paths (e.g. config:status, pm:list, watchdog:show) without
99
+ * re-implementing SSH handling.
96
100
  */
97
- function sshDrush(site, drushArgs, timeoutMs = 30000) {
101
+ export function sshDrush(site, drushArgs, timeoutMs = 30000) {
98
102
  const sshCfg = getDrushConfig(site);
99
103
  assertCommandAllowed(sshCfg, drushArgs[0]);
100
104
  const keyPath = resolveKeyPath(sshCfg.keyPath);
@@ -110,7 +114,7 @@ function sshDrush(site, drushArgs, timeoutMs = 30000) {
110
114
  const drushBin = `${drupalRoot}/vendor/bin/drush`;
111
115
  const command = `cd ${sanitizeSshArg(drupalRoot)} && ${drushBin} ${escapedArgs} --yes`;
112
116
 
113
- console.error(`[drush-bridge] ${site._name}: drush ${drushArgs.join(" ")}`);
117
+ console.error(`[drush-bridge] ${site._name}: drush ${redactSecretArgs(drushArgs)}`);
114
118
 
115
119
  return new Promise((resolve, reject) => {
116
120
  const conn = new Client();
@@ -165,10 +169,28 @@ function sshDrush(site, drushArgs, timeoutMs = 30000) {
165
169
  });
166
170
  }
167
171
 
172
+ /**
173
+ * Redact secret-bearing flag values from a Drush argument list for safe logging.
174
+ * `user:create` passes `--password=…`, and other admin flows may pass tokens/keys;
175
+ * the operational log must not echo them in clear text.
176
+ * @param {string[]} drushArgs The Drush subcommand + flags.
177
+ * @returns {string} A space-joined, secret-redacted rendering for the log line.
178
+ */
179
+ export function redactSecretArgs(drushArgs) {
180
+ const SECRET_FLAG = /^(--(?:password|pass|token|secret|client[-_]?secret|api[-_]?key|key)=)(.*)$/i;
181
+ return drushArgs
182
+ .map((arg) => {
183
+ const m = String(arg).match(SECRET_FLAG);
184
+ return m ? `${m[1]}***` : arg;
185
+ })
186
+ .join(" ");
187
+ }
188
+
168
189
  /**
169
190
  * Parse Drush JSON output; fall back to raw string if not JSON.
191
+ * Exported for reuse by the audit tool groups' drush fallback paths.
170
192
  */
171
- function parseDrush(raw) {
193
+ export function parseDrush(raw) {
172
194
  if (!raw) return null;
173
195
  try { return JSON.parse(raw); }
174
196
  catch { return raw; }
@@ -9,7 +9,118 @@
9
9
 
10
10
  import { getSiteConfig } from "../lib/config.js";
11
11
  import { resolveBackend } from "../lib/backends/index.js";
12
- import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
12
+ import { resolveSecurityConfig, redactCanonicalEntity, assertWriteAllowed } from "../lib/security.js";
13
+ import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
14
+
15
+ /** Fallback language for an alias when the node exposes none. */
16
+ const DEFAULT_ALIAS_LANGCODE = "en";
17
+
18
+ /**
19
+ * Normalize a URL-alias path for storage/comparison: trim, ensure a single
20
+ * leading slash, drop a trailing slash (except root).
21
+ * @param {*} value A raw alias.
22
+ * @returns {?string} The normalized alias, or null when empty.
23
+ */
24
+ function normalizeAlias(value) {
25
+ if (value === undefined || value === null) return null;
26
+ let s = String(value).trim();
27
+ if (!s) return null;
28
+ if (!s.startsWith("/")) s = `/${s}`;
29
+ if (s.length > 1) s = s.replace(/\/+$/, "");
30
+ return s;
31
+ }
32
+
33
+ /**
34
+ * Resolve the `path` attribute to send on an alias-aware node write so the alias
35
+ * actually persists, and decide whether a rename redirect is needed.
36
+ *
37
+ * The bug (DEV-116): JSON:API deserializes `{ alias, pathauto }` onto the node's
38
+ * `path` field, dropping the existing alias's `pid`; Drupal's `PathItem::postSave`
39
+ * then *creates a duplicate* `path_alias` (the older one stays canonical) instead
40
+ * of updating in place. The fix is to round-trip the existing `pid` so the update
41
+ * is in place. DEV-114's path-omitted "preserve" had the same defect (no `pid`),
42
+ * so it is fixed here too.
43
+ *
44
+ * @param {object} args - { backend, type, id, providedPath, isCreate }.
45
+ * @returns {Promise<{pathAttr: (object|undefined), redirect: ?object}>}
46
+ * `pathAttr` is the `path` value to send (or undefined to omit, letting pathauto
47
+ * run on create); `redirect` is `{ from, to, nid }` when a rename redirect is due.
48
+ */
49
+ async function resolvePathWrite({ backend, type, id, providedPath, isCreate }) {
50
+ const info = id
51
+ ? await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}))
52
+ : {};
53
+ const oldAlias = normalizeAlias(info.alias);
54
+ const langcode = info.langcode || DEFAULT_ALIAS_LANGCODE;
55
+
56
+ if (providedPath && typeof providedPath === "object") {
57
+ // Explicit alias set/replace → manual alias, round-trip the existing pid so
58
+ // Drupal updates in place rather than creating a duplicate.
59
+ if (providedPath.alias) {
60
+ const newAlias = normalizeAlias(providedPath.alias);
61
+ const pathAttr = { alias: newAlias, pathauto: false, langcode };
62
+ if (info.pid !== undefined && info.pid !== null) pathAttr.pid = info.pid;
63
+ const redirect = !isCreate && oldAlias && oldAlias !== newAlias
64
+ ? { from: oldAlias, to: newAlias, nid: info.drupalId }
65
+ : null;
66
+ return { pathAttr, redirect };
67
+ }
68
+ // Caller passed `path` without an alias (e.g. re-enabling pathauto) — respect it as-is.
69
+ return { pathAttr: providedPath, redirect: null };
70
+ }
71
+
72
+ // No explicit path on update → preserve the current alias *with its pid* so the
73
+ // save can neither revert nor duplicate it.
74
+ if (!isCreate && oldAlias) {
75
+ const pathAttr = { alias: oldAlias, pathauto: false, langcode };
76
+ if (info.pid !== undefined && info.pid !== null) pathAttr.pid = info.pid;
77
+ return { pathAttr, redirect: null };
78
+ }
79
+
80
+ // Create without an explicit path → omit it so pathauto generates the alias.
81
+ return { pathAttr: undefined, redirect: null };
82
+ }
83
+
84
+ /**
85
+ * Best-effort create a 301 redirect from a node's old alias to the node after a
86
+ * rename, so the previous URL keeps resolving. Governed like any redirect write;
87
+ * never fails the node update (failures are reported, not thrown). Idempotent:
88
+ * skips when a redirect already exists for the source.
89
+ *
90
+ * @param {object} backend Resolved backend.
91
+ * @param {object} sec Resolved security config.
92
+ * @param {{from: string, to: string, nid: ?(number|string)}} redirect
93
+ * @returns {Promise<object>} Outcome `{ created, ... }` for the response.
94
+ */
95
+ async function createRenameRedirect(backend, sec, redirect) {
96
+ try {
97
+ assertWriteAllowed(sec, "create", REDIRECT_ENTITY_TYPE, REDIRECT_ENTITY_TYPE);
98
+ } catch {
99
+ return { created: false, reason: "redirect creation not permitted by policy", source: redirect.from };
100
+ }
101
+ // Prefer an alias-independent target so a future rename can't break the redirect.
102
+ const target = redirect.nid !== undefined && redirect.nid !== null
103
+ ? `entity:node/${redirect.nid}`
104
+ : redirect.to;
105
+ const sourceStored = redirect.from.replace(/^\/+/, "");
106
+ try {
107
+ const existing = await backend.listEntities({
108
+ entityType: REDIRECT_ENTITY_TYPE, bundle: REDIRECT_ENTITY_TYPE,
109
+ filters: [{ field: "redirect_source.path", op: "eq", value: sourceStored }],
110
+ page: { limit: 1 },
111
+ }).catch(() => null);
112
+ if (existing?.entities?.length) {
113
+ return { created: false, reason: "redirect already exists", source: redirect.from };
114
+ }
115
+ await backend.createEntity({
116
+ entityType: REDIRECT_ENTITY_TYPE, bundle: REDIRECT_ENTITY_TYPE,
117
+ attributes: buildRedirectAttributes(redirect.from, target, 301),
118
+ });
119
+ return { created: true, source: redirect.from, target };
120
+ } catch (err) {
121
+ return { created: false, reason: err?.message || String(err), source: redirect.from };
122
+ }
123
+ }
13
124
 
14
125
  /**
15
126
  * Build a Drupal body field descriptor from plain HTML + optional summary.
@@ -119,7 +230,16 @@ async function createNode({ site: siteName, type, title, body, summary, status,
119
230
  if (bodyAttr) attributes.body = bodyAttr;
120
231
  if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes };
121
232
  const backend = await resolveBackend(site);
122
- return backend.createEntity({ entityType: "node", bundle: type, attributes });
233
+ // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
234
+ // `path` is omitted so pathauto generates the alias (DEV-116).
235
+ const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
236
+ if (pathAttr === undefined) delete attributes.path;
237
+ else attributes.path = pathAttr;
238
+ const created = await backend.createEntity({ entityType: "node", bundle: type, attributes });
239
+ // Honest response: re-read so the persisted alias (explicit or pathauto-generated)
240
+ // is reflected rather than the pre-alias write response.
241
+ const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
242
+ return fresh ?? created;
123
243
  }
124
244
 
125
245
  /**
@@ -149,13 +269,21 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
149
269
  if (bodyAttr) attributes.body = bodyAttr;
150
270
  if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes };
151
271
  const backend = await resolveBackend(site);
152
- // Preserve the existing URL alias when the caller didn't set `path`: read the
153
- // current alias and re-pin it (pathauto off) so the save can't revert it.
154
- if (attributes.path === undefined) {
155
- const current = await backend.getEntity({ entityType: "node", bundle: type, id });
156
- if (current?.url) attributes.path = { alias: current.url, pathauto: 0 };
157
- }
158
- return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
272
+ const sec = resolveSecurityConfig(site);
273
+ // Alias handling (DEV-116): an explicit `path.alias` is set in place by
274
+ // round-tripping the existing alias's pid (no duplicate); a path-less update
275
+ // re-pins the current alias *with its pid* so the save can't revert/duplicate
276
+ // it. A rename (alias changed) also gets a 301 redirect from the old path.
277
+ const { pathAttr, redirect } = await resolvePathWrite({ backend, type, id, providedPath: attributes.path, isCreate: false });
278
+ if (pathAttr === undefined) delete attributes.path;
279
+ else attributes.path = pathAttr;
280
+ await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
281
+ const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
282
+ // Honest response: re-read persisted state so the returned `url` is the alias
283
+ // that actually resolves, never the just-sent value.
284
+ const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
285
+ if (fresh && redirectResult) return { ...fresh, _redirect: redirectResult };
286
+ return fresh ?? { id };
159
287
  }
160
288
 
161
289
  /**
@@ -83,6 +83,32 @@ function assertStatusCode(code) {
83
83
  return code;
84
84
  }
85
85
 
86
+ /** Entity type id for redirects, exported so side-effect callers can reuse it. */
87
+ export const REDIRECT_ENTITY_TYPE = REDIRECT_TYPE;
88
+
89
+ /**
90
+ * Build the JSON:API attribute map for a `redirect` entity from a plain
91
+ * source/target, applying the leading-slash and link-URI normalization that
92
+ * keeps a created redirect live. Exported so other tools (e.g. the node
93
+ * rename-redirect side-effect in nodes.js) create redirects identically.
94
+ *
95
+ * @param {string} source Old path (leading slash optional; stripped).
96
+ * @param {string} target Destination path / `entity:node/ID` / absolute URL.
97
+ * @param {number} [statusCode] HTTP redirect code (default 301).
98
+ * @param {string} [language] Redirect language (default 'und').
99
+ * @returns {object} The redirect attribute map for backend.createEntity.
100
+ * @throws {Error} If the status code is unsupported.
101
+ */
102
+ export function buildRedirectAttributes(source, target, statusCode = 301, language = "und") {
103
+ assertStatusCode(statusCode);
104
+ return {
105
+ redirect_source: { path: normalizeSource(source), query: null },
106
+ redirect_redirect: { uri: normalizeTargetUri(target) },
107
+ status_code: statusCode,
108
+ language,
109
+ };
110
+ }
111
+
86
112
  /**
87
113
  * Create an active URL redirect.
88
114
  *
@@ -98,18 +124,14 @@ function assertStatusCode(code) {
98
124
  async function createRedirect({ site: siteName, source, target, statusCode = 301, language = "und" }) {
99
125
  if (!source) throw new Error("A redirect 'source' path is required (e.g. '/old-path').");
100
126
  if (!target) throw new Error("A redirect 'target' is required (a path, 'entity:node/ID', or an absolute URL).");
101
- assertStatusCode(statusCode);
102
127
  const site = getSiteConfig(siteName);
103
128
  const sec = resolveSecurityConfig(site);
104
129
  assertWriteAllowed(sec, "create", REDIRECT_TYPE, REDIRECT_TYPE);
105
130
  const backend = await resolveBackend(site);
106
- const attributes = {
107
- redirect_source: { path: normalizeSource(source), query: null },
108
- redirect_redirect: { uri: normalizeTargetUri(target) },
109
- status_code: statusCode,
110
- language,
111
- };
112
- return backend.createEntity({ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE, attributes });
131
+ return backend.createEntity({
132
+ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE,
133
+ attributes: buildRedirectAttributes(source, target, statusCode, language),
134
+ });
113
135
  }
114
136
 
115
137
  /**