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
|
@@ -110,7 +110,7 @@ export function normalizeWorkflow(raw) {
|
|
|
110
110
|
* @param {string} namespace
|
|
111
111
|
* @returns {string}
|
|
112
112
|
*/
|
|
113
|
-
|
|
113
|
+
function resolvedName(def, namespace) {
|
|
114
114
|
return def.name || workflowPromptName(namespace, def.id);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -141,7 +141,7 @@ export function publicToolName(namespace, alias, tools) {
|
|
|
141
141
|
* @param {{builtin?: boolean}} [opts]
|
|
142
142
|
* @returns {string[]|null} Public names, or null when any tool is missing.
|
|
143
143
|
*/
|
|
144
|
-
|
|
144
|
+
function resolveToolNames(def, namespace, tools, opts = {}) {
|
|
145
145
|
const visible = new Set((tools ?? []).map((tool) => tool.name));
|
|
146
146
|
const names = [];
|
|
147
147
|
for (const alias of def.tools) {
|
|
@@ -270,7 +270,7 @@ export function renderWorkflowMessages(workflow, args = {}) {
|
|
|
270
270
|
* @param {string} instructions
|
|
271
271
|
* @returns {string[]}
|
|
272
272
|
*/
|
|
273
|
-
|
|
273
|
+
function toolAliasesInInstructions(instructions) {
|
|
274
274
|
const aliases = [];
|
|
275
275
|
const seen = new Set();
|
|
276
276
|
for (const match of String(instructions ?? "").matchAll(/\{tool:([a-z][a-z0-9_]{0,47})\}/g)) {
|
|
@@ -287,7 +287,7 @@ export function toolAliasesInInstructions(instructions) {
|
|
|
287
287
|
* @param {object} got
|
|
288
288
|
* @returns {string}
|
|
289
289
|
*/
|
|
290
|
-
|
|
290
|
+
function instructionTextFromDrupalPrompt(got) {
|
|
291
291
|
const messages = Array.isArray(got?.messages) ? got.messages : [];
|
|
292
292
|
const parts = [];
|
|
293
293
|
for (const message of messages) {
|
|
@@ -313,7 +313,7 @@ export function instructionTextFromDrupalPrompt(got) {
|
|
|
313
313
|
* @param {object} toolsMap `serverTools.modules.tools`
|
|
314
314
|
* @returns {boolean}
|
|
315
315
|
*/
|
|
316
|
-
|
|
316
|
+
function inferReadOnly(aliases, toolsMap) {
|
|
317
317
|
for (const alias of aliases) {
|
|
318
318
|
const operation = toolsMap?.[alias]?.operation;
|
|
319
319
|
if (operation === "write" || operation === "delete") return false;
|
|
@@ -26,16 +26,16 @@ import * as reportsContent from "./reports-content.js";
|
|
|
26
26
|
const SECTIONS = [
|
|
27
27
|
// Content
|
|
28
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 }) },
|
|
29
|
+
{ key: "stale_content", needsType: true, 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", needsType: true, 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", needsType: true, 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", needsType: true, 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", needsType: true, 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", needsType: true, 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
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 }) },
|
|
36
|
+
{ key: "broken_links", needsType: true, 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
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 }) },
|
|
38
|
+
{ key: "alias_coverage", needsType: true, 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
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
40
|
// Config
|
|
41
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 }) },
|
|
@@ -81,10 +81,13 @@ function grade(high, issues) {
|
|
|
81
81
|
/**
|
|
82
82
|
* Run a battery of audits and roll the results into a scored dashboard.
|
|
83
83
|
*
|
|
84
|
+
* Sections that audit one content type (`needsType`) are reported unavailable
|
|
85
|
+
* when `type` is omitted; there is no default bundle (#403).
|
|
86
|
+
*
|
|
84
87
|
* @param {object} args - { site?, type?, sampleSize?, sections? }.
|
|
85
88
|
* @returns {Promise<object>} The dashboard: per-section status + a roll-up grade.
|
|
86
89
|
*/
|
|
87
|
-
async function siteHealth({ site: siteName, type
|
|
90
|
+
async function siteHealth({ site: siteName, type, sampleSize = 50, sections }) {
|
|
88
91
|
const site = getSiteConfig(siteName);
|
|
89
92
|
const selected = sections && sections.length
|
|
90
93
|
? SECTIONS.filter((s) => sections.includes(s.key))
|
|
@@ -95,6 +98,13 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
|
|
|
95
98
|
let totalHigh = 0;
|
|
96
99
|
let totalIssues = 0;
|
|
97
100
|
for (const section of selected) {
|
|
101
|
+
if (section.needsType && !type) {
|
|
102
|
+
results.push({
|
|
103
|
+
key: section.key, group: section.group, status: "unavailable",
|
|
104
|
+
reason: "This section audits one content type: pass type. Call drupal_list_content_types to see the bundles on this site.",
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
98
108
|
try {
|
|
99
109
|
const result = await section.run(opts);
|
|
100
110
|
if (isGated(result)) {
|
|
@@ -113,7 +123,7 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
|
|
|
113
123
|
const byStatus = (s) => results.filter((r) => r.status === s).length;
|
|
114
124
|
return {
|
|
115
125
|
site: site._name,
|
|
116
|
-
type,
|
|
126
|
+
type: type || null,
|
|
117
127
|
grade: grade(totalHigh, totalIssues),
|
|
118
128
|
summary: {
|
|
119
129
|
sectionsRun: results.length,
|
|
@@ -139,7 +149,7 @@ export const definitions = [
|
|
|
139
149
|
type: "object",
|
|
140
150
|
properties: {
|
|
141
151
|
site: { type: "string" },
|
|
142
|
-
type: { type: "string", description: "
|
|
152
|
+
type: { type: "string", description: "Content type for the content/link sections. Omit it and those sections are reported unavailable; site-wide sections still run." },
|
|
143
153
|
sampleSize: { type: "number", default: 50, description: "Per-section scan cap (kept small for a fast roll-up)" },
|
|
144
154
|
sections: { type: "array", items: { type: "string", enum: SECTION_KEYS }, description: `Subset of sections to run (default: all). Available: ${SECTION_KEYS.join(", ")}` },
|
|
145
155
|
},
|
package/src/tools/codegen.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { getSiteConfig } from "../lib/config.js";
|
|
19
19
|
import { sshDrush } from "./drush.js";
|
|
20
20
|
import { callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
|
|
21
|
-
import { SecurityError } from "../lib/security.js";
|
|
21
|
+
import { SecurityError, configReadBindingScope } from "../lib/security.js";
|
|
22
22
|
import { validateMachineName } from "../lib/validate.js";
|
|
23
23
|
|
|
24
24
|
const INSPECT = "graphql-compose-codegen:inspect";
|
|
@@ -83,7 +83,7 @@ async function runGqcc(
|
|
|
83
83
|
]).get(subcommand);
|
|
84
84
|
const result = toolResultData(await callBoundModuleTool(site, binding, {
|
|
85
85
|
bundles: names, skip_fields: skip,
|
|
86
|
-
}, { operation: "read", scope:
|
|
86
|
+
}, { operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"] }));
|
|
87
87
|
if (result?.success !== true || !result.data || typeof result.data !== "object" || Array.isArray(result.data)) {
|
|
88
88
|
throw new SecurityError("Codegen module returned an invalid result. No SSH fallback was attempted.");
|
|
89
89
|
}
|
package/src/tools/config.js
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
assertCoreExtensionValueKeepsProtected,
|
|
25
25
|
isCoreExtensionConfig,
|
|
26
26
|
hasScope,
|
|
27
|
+
hasConfigReadScope,
|
|
28
|
+
configReadBindingScope,
|
|
27
29
|
CORE_EXTENSION_CONFIG,
|
|
28
30
|
SecurityError,
|
|
29
31
|
} from "../lib/security.js";
|
|
@@ -36,8 +38,9 @@ import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../
|
|
|
36
38
|
*/
|
|
37
39
|
async function configTool(site, binding, args, operation, capability) {
|
|
38
40
|
if (site.serverTools?.bindings !== undefined) {
|
|
41
|
+
const scope = operation === "read" ? configReadBindingScope(site) : "mcp_config";
|
|
39
42
|
return callBoundModuleTool(site, binding, args, {
|
|
40
|
-
operation, scope
|
|
43
|
+
operation, scope, capabilities: [capability],
|
|
41
44
|
});
|
|
42
45
|
}
|
|
43
46
|
return callGovernedServerTool(site, binding, args);
|
|
@@ -144,7 +147,7 @@ async function assertCoreExtensionWriteAllowed(site, sec, value) {
|
|
|
144
147
|
async function configSet({ site: siteName, name, value }) {
|
|
145
148
|
const site = getSiteConfig(siteName);
|
|
146
149
|
const sec = resolveSecurityConfig(site);
|
|
147
|
-
assertConfigScope(site, `config:set ${name}
|
|
150
|
+
assertConfigScope(site, `config:set ${name}`, { write: true });
|
|
148
151
|
assertNotReadOnly(sec, `config:set ${name}`);
|
|
149
152
|
assertConfigWriteAllowed(sec);
|
|
150
153
|
if (isCoreExtensionConfig(name)) await assertCoreExtensionWriteAllowed(site, sec, value);
|
|
@@ -188,11 +191,13 @@ async function whoami({ site: siteName, _resolvedSource }) {
|
|
|
188
191
|
const summary = getSecuritySummary(site);
|
|
189
192
|
// Effective capability = connector preset AND the scope the server demands.
|
|
190
193
|
// Reporting the preset alone over-states what the token can do — e.g. the
|
|
191
|
-
// content-editor preset allows config reads locally, but
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
+
// content-editor preset allows config reads locally, but config_* tools are
|
|
195
|
+
// gated server-side on mcp_config (writes) or mcp_config / mcp_config_read
|
|
196
|
+
// (reads). The content tier holds neither. When no OAuth scopes are
|
|
197
|
+
// configured, hasScope() is a no-op (preset-only).
|
|
194
198
|
const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
|
|
195
199
|
const canConfig = hasScope(site, "mcp_config");
|
|
200
|
+
const canConfigRead = hasConfigReadScope(site);
|
|
196
201
|
const identity = getRequestIdentity();
|
|
197
202
|
// Dispatch may inject `site` after a default/grant resolution. Prefer the
|
|
198
203
|
// authoritative source so `target` and `_target` cannot disagree (#167).
|
|
@@ -212,7 +217,7 @@ async function whoami({ site: siteName, _resolvedSource }) {
|
|
|
212
217
|
read: hasScope(site, "mcp_read"),
|
|
213
218
|
write: canWrite,
|
|
214
219
|
delete: sec.allowDestructive && canWrite,
|
|
215
|
-
configRead: sec.allowConfigRead &&
|
|
220
|
+
configRead: sec.allowConfigRead && canConfigRead,
|
|
216
221
|
configWrite: sec.allowConfigWrite && !sec.readOnly && canConfig,
|
|
217
222
|
// Local publish policy (allowPublish), symmetric with delete/config caps.
|
|
218
223
|
// Defaults false in every preset except `development`; the remote Drupal's
|
package/src/tools/entities.js
CHANGED
|
@@ -308,7 +308,7 @@ export const definitions = [
|
|
|
308
308
|
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
309
309
|
attributes: { type: "object" },
|
|
310
310
|
relationships: { type: "object" },
|
|
311
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A
|
|
311
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
|
|
312
312
|
returning: RETURNING_SCHEMA,
|
|
313
313
|
},
|
|
314
314
|
},
|
package/src/tools/index.js
CHANGED
|
@@ -41,7 +41,7 @@ import * as config from "./config.js";
|
|
|
41
41
|
import * as codegen from "./codegen.js";
|
|
42
42
|
import { SITE_PARAM } from "../lib/site-target.js";
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
45
45
|
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
46
46
|
reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
|
|
47
47
|
|
package/src/tools/media.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
16
16
|
} from "../lib/security.js";
|
|
17
17
|
import {
|
|
18
|
-
assertDraftLangcode, readDraftTranslation, readTranslationInventory,
|
|
18
|
+
assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
|
|
19
19
|
} from "../lib/sentinel-draft.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
21
|
import { entityRevisionId } from "../lib/write-revision.js";
|
|
@@ -69,7 +69,7 @@ async function getMedia({ site: siteName, type, id, langcode }) {
|
|
|
69
69
|
const targetLang = assertDraftLangcode(langcode);
|
|
70
70
|
const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
|
|
71
71
|
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
72
|
-
if (workingRow && workingRow
|
|
72
|
+
if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
|
|
73
73
|
const entity = await readDraftTranslation(backend, {
|
|
74
74
|
entityType: "media", bundle: type, id, langcode: targetLang,
|
|
75
75
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
package/src/tools/nodes.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { getSiteConfig } from "../lib/config.js";
|
|
11
11
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
12
|
+
import { readableNodeBundles, listNodesAcrossBundles } from "../lib/reports-support.js";
|
|
12
13
|
import {
|
|
13
14
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
14
15
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
@@ -19,7 +20,9 @@ import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity
|
|
|
19
20
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
21
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
22
|
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
|
|
25
|
+
} from "../lib/sentinel-draft.js";
|
|
23
26
|
import { paragraphResourceVersion } from "./paragraphs.js";
|
|
24
27
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
25
28
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
@@ -294,7 +297,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion, in
|
|
|
294
297
|
const targetLang = assertDraftLangcode(langcode);
|
|
295
298
|
const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
|
|
296
299
|
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
297
|
-
if (workingRow && workingRow
|
|
300
|
+
if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
|
|
298
301
|
const entity = await readDraftTranslation(backend, {
|
|
299
302
|
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
300
303
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
@@ -355,7 +358,10 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
|
|
|
355
358
|
}
|
|
356
359
|
|
|
357
360
|
/**
|
|
358
|
-
* Search nodes by title substring
|
|
361
|
+
* Search nodes by title substring. With `type`, searches that bundle. Without
|
|
362
|
+
* it, searches every node bundle the connector may read and merges by
|
|
363
|
+
* `changed` (newest first). A bundle that fails to load fails the search with
|
|
364
|
+
* the bundle named, so a partial result is never returned as complete (#403).
|
|
359
365
|
*
|
|
360
366
|
* @param {object} args - { site?, query, type?, status?, limit? }.
|
|
361
367
|
* @returns {Promise<object[]>} Redacted matching nodes.
|
|
@@ -363,13 +369,15 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
|
|
|
363
369
|
async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
|
|
364
370
|
const site = getSiteConfig(siteName);
|
|
365
371
|
const sec = resolveSecurityConfig(site);
|
|
366
|
-
|
|
367
|
-
assertReadAllowed(sec, "node", bundle);
|
|
372
|
+
assertReadAllowed(sec, "node", type);
|
|
368
373
|
const backend = await resolveBackend(site);
|
|
369
374
|
const filters = [{ field: "title", op: "contains", value: query }];
|
|
370
375
|
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
371
|
-
const
|
|
372
|
-
|
|
376
|
+
const descriptor = { filters, sort: [{ field: "changed", dir: "desc" }] };
|
|
377
|
+
const found = type
|
|
378
|
+
? (await backend.listEntities({ ...descriptor, entityType: "node", bundle: type, page: { limit } })).entities
|
|
379
|
+
: await listNodesAcrossBundles(backend, await readableNodeBundles(backend, sec), descriptor, limit);
|
|
380
|
+
return found.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
373
381
|
}
|
|
374
382
|
|
|
375
383
|
/**
|
|
@@ -619,7 +627,7 @@ export const definitions = [
|
|
|
619
627
|
properties: {
|
|
620
628
|
site: { type: "string" },
|
|
621
629
|
query: { type: "string", description: "Search term to match against node titles" },
|
|
622
|
-
type: { type: "string", description: "
|
|
630
|
+
type: { type: "string", description: "Content type machine name. Omit to search every node bundle (merged, newest first); if any bundle cannot be read the search fails and names it." },
|
|
623
631
|
status: { type: "boolean", description: "Filter by publish status" },
|
|
624
632
|
limit: { type: "number", default: 10 },
|
|
625
633
|
},
|
|
@@ -664,7 +672,7 @@ export const definitions = [
|
|
|
664
672
|
langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
|
|
665
673
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
|
|
666
674
|
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
|
|
667
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A
|
|
675
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
|
|
668
676
|
returning: RETURNING_SCHEMA,
|
|
669
677
|
},
|
|
670
678
|
},
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { getSiteConfig } from "../lib/config.js";
|
|
21
|
-
import { resolveSecurityConfig, assertConfigReadAllowed } from "../lib/security.js";
|
|
21
|
+
import { resolveSecurityConfig, assertConfigReadAllowed, configReadBindingScope } from "../lib/security.js";
|
|
22
22
|
import { gatedReport } from "../lib/reports-support.js";
|
|
23
23
|
import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
|
|
24
24
|
import { runPrivileged, serverToolsConfigured, drushConfigured } from "../lib/audit-sources.js";
|
|
@@ -67,7 +67,7 @@ function configSourceAvailable(site) {
|
|
|
67
67
|
async function readConfig(site, name) {
|
|
68
68
|
if (site.serverTools?.bindings !== undefined) {
|
|
69
69
|
return toolResultData(await callBoundModuleTool(site, "configGet", { name }, {
|
|
70
|
-
operation: "read", scope:
|
|
70
|
+
operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
|
|
71
71
|
}));
|
|
72
72
|
}
|
|
73
73
|
if (serverToolsConfigured(site)) {
|
|
@@ -89,7 +89,7 @@ async function readConfig(site, name) {
|
|
|
89
89
|
async function listConfigNames(site, prefix) {
|
|
90
90
|
if (site.serverTools?.bindings !== undefined) {
|
|
91
91
|
return pickConfigNames(toolResultData(await callBoundModuleTool(site, "configList", { prefix }, {
|
|
92
|
-
operation: "read", scope:
|
|
92
|
+
operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
|
|
93
93
|
})));
|
|
94
94
|
}
|
|
95
95
|
if (serverToolsConfigured(site)) {
|