drupal-mcp-connector 2.19.0 → 2.20.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-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +254 -0
- package/README.md +10 -3
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +52 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +40 -5
- package/scripts/install-commands.js +148 -11
- package/src/index.js +9 -12
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/dispatch.js +6 -6
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/errors.js +0 -11
- package/src/lib/evidence.js +0 -6
- package/src/lib/governance.js +2 -8
- package/src/lib/link-checker.js +3 -3
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/operations.js +2 -2
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/policy-enforcement.js +4 -4
- package/src/lib/principal.js +3 -3
- package/src/lib/relay/edge.js +2 -2
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +234 -18
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +188 -27
- package/src/lib/tool-prompts.js +139 -8
- package/src/lib/usage.js +0 -9
- package/src/lib/verify.js +164 -61
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/nodes.js +16 -6
- package/src/tools/paragraphs.js +1 -1
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- package/src/tools/translations.js +5 -1
- package/src/lib/draft-write.js +0 -19
- package/src/lib/node-draft-inventory.js +0 -5
package/src/tools/drush.js
CHANGED
|
@@ -26,7 +26,10 @@ import { readFileSync } from "fs";
|
|
|
26
26
|
import { homedir } from "os";
|
|
27
27
|
import { join, resolve, normalize } from "path";
|
|
28
28
|
import { getSiteConfig } from "../lib/config.js";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
resolveSecurityConfig, assertNotReadOnly, assertDestructiveAllowed, assertModuleUninstallAllowed,
|
|
31
|
+
assertCoreExtensionChangeAllowed, isCoreExtensionConfig, SecurityError,
|
|
32
|
+
} from "../lib/security.js";
|
|
30
33
|
import { validateMachineName, validateSqlQuery, sanitizeSshArg } from "../lib/validate.js";
|
|
31
34
|
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
@@ -128,8 +131,11 @@ function resolveKeyPath(rawPath) {
|
|
|
128
131
|
* Exported so the audit tool groups can reuse the hardened bridge for their
|
|
129
132
|
* drush fallback paths (e.g. config:status, pm:list, watchdog:show) without
|
|
130
133
|
* re-implementing SSH handling.
|
|
134
|
+
*
|
|
135
|
+
* @param {{confirm?: "yes"|"no"}} [options] - how Drush confirmation prompts are
|
|
136
|
+
* answered. Default "yes". "no" makes Drush cancel at any prompt.
|
|
131
137
|
*/
|
|
132
|
-
export function sshDrush(site, drushArgs, timeoutMs = 30000) {
|
|
138
|
+
export function sshDrush(site, drushArgs, timeoutMs = 30000, { confirm = "yes" } = {}) {
|
|
133
139
|
const sshCfg = getDrushConfig(site);
|
|
134
140
|
assertCommandAllowed(sshCfg, drushArgs[0]);
|
|
135
141
|
const keyPath = resolveKeyPath(sshCfg.keyPath);
|
|
@@ -143,7 +149,10 @@ export function sshDrush(site, drushArgs, timeoutMs = 30000) {
|
|
|
143
149
|
// Build the command: cd to Drupal root, then run vendor drush with escaped args
|
|
144
150
|
const escapedArgs = drushArgs.map(sanitizeSshArg).join(" ");
|
|
145
151
|
const drushBin = `${drupalRoot}/vendor/bin/drush`;
|
|
146
|
-
|
|
152
|
+
// Every prompt is answered for the non-interactive session: yes by default,
|
|
153
|
+
// no when the caller must not let Drush widen the operation (#346).
|
|
154
|
+
const answer = confirm === "no" ? "--no" : "--yes";
|
|
155
|
+
const command = `cd ${sanitizeSshArg(drupalRoot)} && ${drushBin} ${escapedArgs} ${answer}`;
|
|
147
156
|
|
|
148
157
|
console.error(`[drush-bridge] ${site._name}: drush ${redactSecretArgs(drushArgs)}`);
|
|
149
158
|
|
|
@@ -175,9 +184,17 @@ export function sshDrush(site, drushArgs, timeoutMs = 30000) {
|
|
|
175
184
|
|
|
176
185
|
stream.on("close", (code) => {
|
|
177
186
|
if (code !== 0) {
|
|
178
|
-
|
|
187
|
+
const failure = new Error(
|
|
179
188
|
`Drush exited ${code}: ${(stderr.trim() || stdout.trim()).slice(0, 500)}`
|
|
180
|
-
)
|
|
189
|
+
);
|
|
190
|
+
// Kept off the message: callers that need to read Drush's own
|
|
191
|
+
// output (the cascade list, #346) get it without it reaching the client.
|
|
192
|
+
Object.defineProperties(failure, {
|
|
193
|
+
exitCode: { value: code },
|
|
194
|
+
drushStdout: { value: stdout },
|
|
195
|
+
drushStderr: { value: stderr },
|
|
196
|
+
});
|
|
197
|
+
settle(reject, failure);
|
|
181
198
|
} else {
|
|
182
199
|
settle(resolve, stdout.trim());
|
|
183
200
|
}
|
|
@@ -302,15 +319,94 @@ async function configExport({ site: siteName }) {
|
|
|
302
319
|
return { success: true, message: "Configuration exported to sync directory." };
|
|
303
320
|
}
|
|
304
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Read the config names `drush config:status` reports as changed.
|
|
324
|
+
*
|
|
325
|
+
* Drush prints nothing, `[]` or `{}` when active config matches the sync
|
|
326
|
+
* directory. Otherwise it prints an object keyed by config name, or a list of
|
|
327
|
+
* `{ name, state }` rows. Any other output is not understood.
|
|
328
|
+
* @param {string} output Trimmed stdout of `config:status --format=json`.
|
|
329
|
+
* @returns {?string[]} Changed config names, or null when the output is not understood.
|
|
330
|
+
*/
|
|
331
|
+
function changedConfigNames(output) {
|
|
332
|
+
if (!output) return [];
|
|
333
|
+
const data = parseDrush(output);
|
|
334
|
+
if (Array.isArray(data)) {
|
|
335
|
+
const names = data.map((row) => (row && typeof row === "object" ? row.name : undefined));
|
|
336
|
+
return names.every((name) => typeof name === "string" && name) ? names : null;
|
|
337
|
+
}
|
|
338
|
+
if (data && typeof data === "object") {
|
|
339
|
+
// Keyed by config name. A row's own `name` is read too, so an object keyed
|
|
340
|
+
// by row index cannot hide a config name from the check.
|
|
341
|
+
const rowNames = Object.values(data)
|
|
342
|
+
.map((row) => (row && typeof row === "object" ? row.name : undefined))
|
|
343
|
+
.filter((name) => typeof name === "string" && name);
|
|
344
|
+
return [...Object.keys(data), ...rowNames];
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Refuse a config import that would change core.extension (#349).
|
|
351
|
+
*
|
|
352
|
+
* `config:import` uninstalls every module that core.extension in the sync
|
|
353
|
+
* directory no longer lists, and the bridge answers the prompt "yes". The
|
|
354
|
+
* connector cannot read the sync directory, so it cannot tell which modules
|
|
355
|
+
* those are. It reads `config:status`, a read-only command, and refuses when
|
|
356
|
+
* core.extension is among the changed objects. Fails closed: if the status
|
|
357
|
+
* cannot be read or understood, nothing is imported.
|
|
358
|
+
*
|
|
359
|
+
* The sync directory can change between this read and the import. The check
|
|
360
|
+
* narrows the path; it does not close a race with someone who can write there.
|
|
361
|
+
* @param {object} site Resolved site config.
|
|
362
|
+
* @param {object} sec Resolved security config.
|
|
363
|
+
* @returns {Promise<void>}
|
|
364
|
+
* @throws {SecurityError} if core.extension differs, or the status is unreadable.
|
|
365
|
+
*/
|
|
366
|
+
async function assertImportKeepsCoreExtension(site, sec) {
|
|
367
|
+
if (sec.allowCoreExtensionChange === true && !sec.coreExtensionChangeError) return;
|
|
368
|
+
const unchecked = "The config import was refused because core.extension could not be checked first. Nothing was imported.";
|
|
369
|
+
let output;
|
|
370
|
+
try {
|
|
371
|
+
output = await sshDrush(site, ["config:status", "--format=json"]);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (err instanceof SecurityError) {
|
|
374
|
+
throw new SecurityError(
|
|
375
|
+
`${unchecked} The check runs \`drush config:status\`, a read-only command, and this site's ` +
|
|
376
|
+
"drushSsh.allowedCommands does not list it. An operator must add \"config:status\" to allowedCommands."
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
throw new SecurityError(`${unchecked} \`drush config:status\` failed: ${String(err?.message ?? err).slice(0, 300)}`);
|
|
380
|
+
}
|
|
381
|
+
const names = changedConfigNames(output);
|
|
382
|
+
if (!names) {
|
|
383
|
+
throw new SecurityError(`${unchecked} The output of \`drush config:status --format=json\` was not understood.`);
|
|
384
|
+
}
|
|
385
|
+
if (names.some(isCoreExtensionConfig)) {
|
|
386
|
+
assertCoreExtensionChangeAllowed(
|
|
387
|
+
sec,
|
|
388
|
+
"The config import was refused because core.extension differs between the site and the sync directory. " +
|
|
389
|
+
"Importing it installs and uninstalls modules, and can uninstall a protected one. " +
|
|
390
|
+
"The connector cannot read the sync directory, so it cannot tell which modules would change. Nothing was imported."
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
305
395
|
/**
|
|
306
396
|
* Import config from the sync directory into the DB (`drush config:import`).
|
|
397
|
+
*
|
|
398
|
+
* Refused when core.extension differs, unless the operator set
|
|
399
|
+
* `security.allowCoreExtensionChange` (#349).
|
|
307
400
|
* @param {object} args - { site? }.
|
|
308
401
|
* @returns {Promise<{success: boolean, message: string}>}
|
|
309
|
-
* @throws {SecurityError} If the site is read-only.
|
|
402
|
+
* @throws {SecurityError} If the site is read-only, core.extension differs, or
|
|
403
|
+
* `config:status` cannot be read.
|
|
310
404
|
*/
|
|
311
405
|
async function configImport({ site: siteName }) {
|
|
312
406
|
const site = getSiteConfig(siteName);
|
|
313
|
-
|
|
407
|
+
const sec = resolveSecurityConfig(site);
|
|
408
|
+
assertNotReadOnly(sec, "drush config:import");
|
|
409
|
+
await assertImportKeepsCoreExtension(site, sec);
|
|
314
410
|
await sshDrush(site, ["config:import"]);
|
|
315
411
|
return { success: true, message: "Configuration imported from sync directory." };
|
|
316
412
|
}
|
|
@@ -366,25 +462,103 @@ async function securityUpdates({ site: siteName }) {
|
|
|
366
462
|
* @throws {Error} If moduleName is not a valid machine name.
|
|
367
463
|
*/
|
|
368
464
|
async function enableModule({ site: siteName, moduleName }) {
|
|
465
|
+
validateMachineName(moduleName, "moduleName"); // first: an invalid name never reaches a message
|
|
369
466
|
const site = getSiteConfig(siteName);
|
|
370
467
|
assertNotReadOnly(resolveSecurityConfig(site), `pm:enable ${moduleName}`);
|
|
371
|
-
validateMachineName(moduleName, "moduleName"); // throws if invalid
|
|
372
468
|
await sshDrush(site, ["pm:enable", moduleName]);
|
|
373
469
|
return { success: true, message: `Module "${moduleName}" enabled.` };
|
|
374
470
|
}
|
|
375
471
|
|
|
376
472
|
/**
|
|
377
|
-
*
|
|
473
|
+
* Drush prints this before it asks to confirm a cascading uninstall. The list
|
|
474
|
+
* may wrap over several lines. Case-sensitive on purpose: module names are
|
|
475
|
+
* lowercase, so the capture stops at the next sentence ("Do you want to…").
|
|
476
|
+
*/
|
|
477
|
+
const CASCADE_LIST_RE = /The following extensions will be uninstalled:\s*([a-z0-9_,\s]+)/;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read the modules Drush said it would uninstall from a cancelled run.
|
|
481
|
+
* @param {Error} failure Rejection from sshDrush().
|
|
482
|
+
* @returns {?string[]} Machine names, or null when Drush printed no list.
|
|
483
|
+
*/
|
|
484
|
+
function cascadeList(failure) {
|
|
485
|
+
return parseCascadeList(`${failure?.drushStdout ?? ""}\n${failure?.drushStderr ?? ""}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* @param {string} output Drush output.
|
|
490
|
+
* @returns {?string[]} Machine names Drush said it would uninstall, or null.
|
|
491
|
+
*/
|
|
492
|
+
function parseCascadeList(output) {
|
|
493
|
+
const match = CASCADE_LIST_RE.exec(output);
|
|
494
|
+
if (!match) return null;
|
|
495
|
+
return match[1].split(",").map((name) => name.trim()).filter((name) => /^[a-z][a-z0-9_]*$/.test(name));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Uninstall a module (`drush pm:uninstall`).
|
|
500
|
+
*
|
|
501
|
+
* Refused for a module on the site's protected list (#346): the default list in
|
|
502
|
+
* `security.js`, plus `security.protectedModules`, minus the explicit
|
|
503
|
+
* `security.allowProtectedModuleUninstall`.
|
|
504
|
+
*
|
|
505
|
+
* `pm:uninstall` also uninstalls every module that depends on the named one,
|
|
506
|
+
* after a prompt. The bridge answers that prompt "no", so a cascade is
|
|
507
|
+
* cancelled by Drush and nothing is uninstalled; a protected module cannot be
|
|
508
|
+
* removed through one of its dependencies. The caller uninstalls each
|
|
509
|
+
* dependent by name, and each name goes through the same check.
|
|
510
|
+
*
|
|
378
511
|
* @param {object} args - { site?, moduleName }.
|
|
379
|
-
* @returns {Promise<{success: boolean, message: string}>}
|
|
380
|
-
*
|
|
381
|
-
* @throws {Error} If moduleName is not a valid machine name.
|
|
512
|
+
* @returns {Promise<{success: boolean, message: string, alsoUninstalled?: string[], warning?: string}>}
|
|
513
|
+
* `alsoUninstalled` is set only if Drush ran a cascade despite the "no" answer.
|
|
514
|
+
* @throws {Error} If moduleName is not a valid machine name. Checked first, so
|
|
515
|
+
* an invalid name never reaches a message.
|
|
516
|
+
* @throws {SecurityError} If the site is read-only, deletes are not allowed,
|
|
517
|
+
* the module is protected, the protected-module config is malformed, or the
|
|
518
|
+
* uninstall would cascade to dependents.
|
|
382
519
|
*/
|
|
383
520
|
async function disableModule({ site: siteName, moduleName }) {
|
|
384
|
-
const site = getSiteConfig(siteName);
|
|
385
|
-
assertNotReadOnly(resolveSecurityConfig(site), `pm:uninstall ${moduleName}`);
|
|
386
521
|
validateMachineName(moduleName, "moduleName");
|
|
387
|
-
|
|
522
|
+
const site = getSiteConfig(siteName);
|
|
523
|
+
const sec = resolveSecurityConfig(site);
|
|
524
|
+
assertNotReadOnly(sec, `pm:uninstall ${moduleName}`);
|
|
525
|
+
assertDestructiveAllowed(sec, "module", moduleName);
|
|
526
|
+
assertModuleUninstallAllowed(sec, moduleName);
|
|
527
|
+
let stdout;
|
|
528
|
+
try {
|
|
529
|
+
stdout = await sshDrush(site, ["pm:uninstall", moduleName], 30000, { confirm: "no" });
|
|
530
|
+
} catch (failure) {
|
|
531
|
+
const list = cascadeList(failure);
|
|
532
|
+
if (!list) throw failure;
|
|
533
|
+
const dependents = list.filter((name) => name !== moduleName);
|
|
534
|
+
if (dependents.length === 0) {
|
|
535
|
+
// Drush 13 prompts only for a cascade. Older releases prompt on every
|
|
536
|
+
// uninstall, and the "no" answer cancels those too.
|
|
537
|
+
throw new SecurityError(
|
|
538
|
+
`Drush asked to confirm uninstalling "${moduleName}" alone and the bridge answers "no", so nothing was uninstalled. ` +
|
|
539
|
+
"This Drush release prompts on every uninstall; the cascade guard needs Drush 13 or later on the site."
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const guarded = dependents.filter((name) => sec.protectedModules.includes(name));
|
|
543
|
+
throw new SecurityError(
|
|
544
|
+
`Uninstalling "${moduleName}" would also uninstall: ${dependents.join(", ")}. ` +
|
|
545
|
+
"Nothing was uninstalled. " +
|
|
546
|
+
(guarded.length ? `Protected: ${guarded.join(", ")}. ` : "") +
|
|
547
|
+
"Uninstall each dependent by name first; each one goes through the protected-module check."
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
// Drush should have cancelled a cascade. If it printed a cascade list and
|
|
551
|
+
// still exited 0, say what it removed instead of reporting a clean uninstall.
|
|
552
|
+
const alsoUninstalled = (parseCascadeList(String(stdout ?? "")) ?? []).filter((name) => name !== moduleName);
|
|
553
|
+
if (alsoUninstalled.length) {
|
|
554
|
+
return {
|
|
555
|
+
success: true,
|
|
556
|
+
message: `Module "${moduleName}" uninstalled.`,
|
|
557
|
+
alsoUninstalled,
|
|
558
|
+
warning: "Drush did not cancel the cascade prompt, so these dependents were uninstalled too: " +
|
|
559
|
+
`${alsoUninstalled.join(", ")}. Check the Drush version on the site.`,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
388
562
|
return { success: true, message: `Module "${moduleName}" uninstalled.` };
|
|
389
563
|
}
|
|
390
564
|
|
|
@@ -514,7 +688,7 @@ export const definitions = [
|
|
|
514
688
|
{ name: "drupal_drush_status", description: "Get Drupal site status via `drush status` — version, DB, file paths, active config.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
|
|
515
689
|
{ name: "drupal_drush_config_status", description: "Check if active config is in sync with the sync directory. Returns out-of-sync items if any.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
|
|
516
690
|
{ name: "drupal_drush_config_export", description: "Export active configuration to the sync directory. Requires write access.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
|
|
517
|
-
{ name: "drupal_drush_config_import", description: "Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
|
|
691
|
+
{ name: "drupal_drush_config_import", description: "Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production. Reads `drush config:status` first and refuses the import when `core.extension` differs, because such an import installs and uninstalls modules and can remove a protected one; nothing is imported, and the status read must be allowed in `drushSsh.allowedCommands`. Use drupal_drush_module_enable or drupal_drush_module_disable for a module change. Only an operator can allow it, with `allowCoreExtensionChange` in site config (see drupal_security_info).", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
|
|
518
692
|
{ name: "drupal_drush_updatedb", description: "Run pending database updates via `drush updatedb`. Always run after deploying module updates.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
|
|
519
693
|
{ name: "drupal_drush_security_updates", description: "List modules with known security advisories via `drush pm:security`.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
|
|
520
694
|
{
|
|
@@ -529,7 +703,7 @@ export const definitions = [
|
|
|
529
703
|
},
|
|
530
704
|
{
|
|
531
705
|
name: "drupal_drush_module_disable",
|
|
532
|
-
description: "Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user.",
|
|
706
|
+
description: "Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user. Refused for a protected module: governance, integrity, secrets, auth and API modules such as mcp_sentinel, audit_chain, key, simple_oauth and jsonapi (see `protectedModules` in drupal_security_info). Only an operator can change that list, in site config. Also refused when the uninstall would cascade to dependents: nothing is uninstalled, the dependents are named, and each must be uninstalled by name first.",
|
|
533
707
|
inputSchema: { type: "object", required: ["moduleName"], properties: { site: { type: "string" }, moduleName: { type: "string", pattern: "^[a-z][a-z0-9_]*$" } } },
|
|
534
708
|
},
|
|
535
709
|
{
|
package/src/tools/entities.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "../lib/err-relationships.js";
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
+
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
21
22
|
import {
|
|
22
23
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
23
24
|
redactCanonicalEntity, getSecuritySummary,
|
|
@@ -71,7 +72,12 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
71
72
|
assertPublishAllowed(sec, attributes);
|
|
72
73
|
const backend = await resolveBackend(site);
|
|
73
74
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
74
|
-
if (dryRun)
|
|
75
|
+
if (dryRun) {
|
|
76
|
+
return {
|
|
77
|
+
dryRun: true, operation: "create", entityType, bundle, attributes, relationships: resolvedRelationships,
|
|
78
|
+
...dryRunChecks({ operation: "create", preflight: PREFLIGHT_NONE }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
75
81
|
const created = await backend.createEntity({ entityType, bundle, attributes, relationships: resolvedRelationships });
|
|
76
82
|
if (entityType === "paragraph") {
|
|
77
83
|
const revisionId = await resolveParagraphRevisionId(backend, created, bundle);
|
|
@@ -125,6 +131,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
125
131
|
return {
|
|
126
132
|
dryRun: true, operation: "update", entityType, bundle, id,
|
|
127
133
|
attributes: safeAttributes, relationships: resolvedRelationships,
|
|
134
|
+
...dryRunChecks({ operation: "update", preflight: patchTarget.preflight }),
|
|
128
135
|
};
|
|
129
136
|
}
|
|
130
137
|
const result = await updateEntityGuarded(backend, {
|
|
@@ -157,7 +164,12 @@ async function deleteEntity({ site: siteName, entityType, bundle, id, dryRun = f
|
|
|
157
164
|
const site = getSiteConfig(siteName);
|
|
158
165
|
const sec = resolveSecurityConfig(site);
|
|
159
166
|
assertDeleteAllowed(sec, entityType, bundle, id);
|
|
160
|
-
if (dryRun)
|
|
167
|
+
if (dryRun) {
|
|
168
|
+
return {
|
|
169
|
+
dryRun: true, operation: "delete", entityType, bundle, id,
|
|
170
|
+
...dryRunChecks({ operation: "delete", preflight: PREFLIGHT_NONE }),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
161
173
|
const backend = await resolveBackend(site);
|
|
162
174
|
await backend.deleteEntity({ entityType, bundle, id });
|
|
163
175
|
return { success: true, deletedId: id, entityType, bundle };
|
|
@@ -277,14 +289,14 @@ export const definitions = [
|
|
|
277
289
|
bundle: { type: "string" },
|
|
278
290
|
attributes: { type: "object", description: "Field values keyed by Drupal machine name" },
|
|
279
291
|
relationships: { type: "object", description: "Relationship data keyed by field name" },
|
|
280
|
-
dryRun: { type: "boolean", default: false, description: "
|
|
292
|
+
dryRun: { type: "boolean", default: false, description: "Return a preview of the payload without committing. Drupal does not evaluate the write: entity access, field access and entity validation are NOT checked, so the real create can still fail with a 403 or a validation 422. The result's `checks` block and `caveat` say what was and was not checked." },
|
|
281
293
|
returning: RETURNING_SCHEMA,
|
|
282
294
|
},
|
|
283
295
|
},
|
|
284
296
|
},
|
|
285
297
|
{
|
|
286
298
|
name: "drupal_entity_update",
|
|
287
|
-
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
|
|
299
|
+
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
|
|
288
300
|
inputSchema: {
|
|
289
301
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
290
302
|
properties: {
|
|
@@ -295,7 +307,7 @@ export const definitions = [
|
|
|
295
307
|
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
296
308
|
attributes: { type: "object" },
|
|
297
309
|
relationships: { type: "object" },
|
|
298
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers,
|
|
310
|
+
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 published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
|
|
299
311
|
returning: RETURNING_SCHEMA,
|
|
300
312
|
},
|
|
301
313
|
},
|
|
@@ -310,7 +322,7 @@ export const definitions = [
|
|
|
310
322
|
entityType: { type: "string" },
|
|
311
323
|
bundle: { type: "string" },
|
|
312
324
|
id: { type: "string" },
|
|
313
|
-
dryRun: { type: "boolean", default: false, description: "
|
|
325
|
+
dryRun: { type: "boolean", default: false, description: "Return a preview of the delete without committing. Drupal does not evaluate the delete: Drupal's delete access for the entity is NOT checked, only the connector's own policy. The result's `checks` block says so." },
|
|
314
326
|
},
|
|
315
327
|
},
|
|
316
328
|
},
|
package/src/tools/fields.js
CHANGED
|
@@ -27,6 +27,17 @@ const SAMPLING_NOTE =
|
|
|
27
27
|
"cardinality, allowed values, widget/storage settings) use the Drush bridge " +
|
|
28
28
|
"(field config), which reads the Field API directly.";
|
|
29
29
|
|
|
30
|
+
const NOT_VISIBLE_NOTE =
|
|
31
|
+
"These fields are defined in Drupal's Field API for this bundle but are absent from the sampled entity. " +
|
|
32
|
+
"JSON:API keeps the key of an empty field and leaves out a field the account may not view, so each one is " +
|
|
33
|
+
"most likely denied to this account. It may also be disabled or renamed in JSON:API. " +
|
|
34
|
+
"An absent field is not an empty field: do not read these as unset. The list covers configurable fields " +
|
|
35
|
+
"only (field_config, first 50) and one sampled entity, so it may be incomplete.";
|
|
36
|
+
|
|
37
|
+
const DEFINITIONS_UNAVAILABLE_NOTE =
|
|
38
|
+
" Field definitions (JSON:API field_config) are not readable here, so a field this account may not view " +
|
|
39
|
+
"cannot be detected: JSON:API leaves a view-denied field out of the resource, and it is simply missing from this list.";
|
|
40
|
+
|
|
30
41
|
const EMPTY_SCHEMA_NOTE =
|
|
31
42
|
"No entities of this type/bundle exist yet, so sampling found no fields. " +
|
|
32
43
|
"Create one entity, or use the Drush bridge (Field API), to introspect fields.";
|
|
@@ -75,8 +86,10 @@ function relationshipField(name) {
|
|
|
75
86
|
* @param {object} args - { site?, type|entityType, bundle? }. `bundle` defaults
|
|
76
87
|
* to the entity type (matching Drupal's single-bundle types, e.g. `user`).
|
|
77
88
|
* @returns {Promise<{entityType: string, bundle: string, resourceType?: string,
|
|
78
|
-
* approximate: true, fieldCount: number, fields: object[],
|
|
79
|
-
*
|
|
89
|
+
* approximate: true, fieldCount: number, fields: object[],
|
|
90
|
+
* fieldDefinitions: "available"|"unavailable",
|
|
91
|
+
* notVisible?: Array<{name: string, translatable: boolean}>, notVisibleNote?: string,
|
|
92
|
+
* note: string, authoritativeSource: string}>}
|
|
80
93
|
* @throws {SecurityError} If reading the type/bundle is not permitted.
|
|
81
94
|
* @throws {Error} If no entity type is given under either name.
|
|
82
95
|
*/
|
|
@@ -111,6 +124,20 @@ async function describeFields({ site: siteName, type, entityType: entityTypeArg,
|
|
|
111
124
|
|
|
112
125
|
const sampledEmpty = fields.length === 0;
|
|
113
126
|
|
|
127
|
+
// #337: a view-denied field is left out of the JSON:API resource, so sampling
|
|
128
|
+
// never sees it. The Field API definitions already read above name the
|
|
129
|
+
// configurable fields of the bundle; any of those missing from the sampled
|
|
130
|
+
// KEYS (not values — an empty field keeps its key) is listed apart. Nothing
|
|
131
|
+
// is claimed when no entity was sampled or the definitions are unreadable.
|
|
132
|
+
const definitionsAvailable = translatableMap.size > 0;
|
|
133
|
+
const sampledNames = new Set(fields.map((field) => field.name));
|
|
134
|
+
const notVisible = definitionsAvailable && !sampledEmpty
|
|
135
|
+
? [...translatableMap.entries()]
|
|
136
|
+
.filter(([name]) => !sampledNames.has(name))
|
|
137
|
+
.map(([name, value]) => ({ name, translatable: Boolean(value) }))
|
|
138
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
139
|
+
: null;
|
|
140
|
+
|
|
114
141
|
return {
|
|
115
142
|
entityType: schema.entityType ?? entityType,
|
|
116
143
|
bundle: schema.bundle ?? resolvedBundle,
|
|
@@ -118,7 +145,12 @@ async function describeFields({ site: siteName, type, entityType: entityTypeArg,
|
|
|
118
145
|
approximate: true,
|
|
119
146
|
fieldCount: fields.length,
|
|
120
147
|
fields,
|
|
121
|
-
|
|
148
|
+
fieldDefinitions: definitionsAvailable ? "available" : "unavailable",
|
|
149
|
+
...(notVisible ? { notVisible } : {}),
|
|
150
|
+
...(notVisible?.length ? { notVisibleNote: NOT_VISIBLE_NOTE } : {}),
|
|
151
|
+
note: sampledEmpty
|
|
152
|
+
? EMPTY_SCHEMA_NOTE
|
|
153
|
+
: SAMPLING_NOTE + (definitionsAvailable ? "" : DEFINITIONS_UNAVAILABLE_NOTE),
|
|
122
154
|
authoritativeSource: "drush-bridge (Drupal Field API)",
|
|
123
155
|
};
|
|
124
156
|
}
|
|
@@ -136,7 +168,11 @@ export const definitions = [
|
|
|
136
168
|
"schema SAMPLING (an existing entity), so results are approximate — only " +
|
|
137
169
|
"populated fields are visible and required/cardinality/allowedValues are " +
|
|
138
170
|
"inferred from value shape. When JSON:API field_config is readable, translatable " +
|
|
139
|
-
"is copied from Field API; omitted means unknown, not false.
|
|
171
|
+
"is copied from Field API; omitted means unknown, not false. A field this account may not " +
|
|
172
|
+
"view is left out of the JSON:API resource, so it is NOT in `fields` and looks like a field that " +
|
|
173
|
+
"does not exist. When field_config is readable (`fieldDefinitions: 'available'`), fields defined " +
|
|
174
|
+
"for the bundle but absent from the sampled entity are listed apart as `notVisible`; when it is " +
|
|
175
|
+
"not, denied fields cannot be detected. Absent is not empty. Authoritative field " +
|
|
140
176
|
"metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names.",
|
|
141
177
|
inputSchema: {
|
|
142
178
|
type: "object",
|
package/src/tools/graphql.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { getSiteConfig } from "../lib/config.js";
|
|
18
18
|
import { drupalGraphqlFetch } from "../lib/drupal-fetch.js";
|
|
19
|
+
import { cleanGraphqlErrors, describeGraphqlErrors } from "../lib/error-body.js";
|
|
19
20
|
import {
|
|
20
21
|
resolveSecurityConfig, assertGraphqlAllowed, assertGraphqlMutationAllowed,
|
|
21
22
|
} from "../lib/security.js";
|
|
@@ -30,7 +31,8 @@ import {
|
|
|
30
31
|
* @param {object} args - { site?, query, variables?, operationName? }.
|
|
31
32
|
* @returns {Promise<{data: object, warnings?: string[]}>} On a clean response,
|
|
32
33
|
* just `data`. When the server returns errors alongside partial `data`, the
|
|
33
|
-
* error messages are surfaced as `warnings` rather than thrown.
|
|
34
|
+
* error messages are surfaced as `warnings` rather than thrown. Each message
|
|
35
|
+
* is cleaned and the list is bounded (see `cleanGraphqlErrors`).
|
|
34
36
|
* @throws {Error} If the response carries errors and no data at all.
|
|
35
37
|
*/
|
|
36
38
|
async function runGraphql({ site: siteName, query, variables = {}, operationName }) {
|
|
@@ -41,10 +43,11 @@ async function runGraphql({ site: siteName, query, variables = {}, operationName
|
|
|
41
43
|
const json = await drupalGraphqlFetch(site, { query, variables, operationName });
|
|
42
44
|
|
|
43
45
|
if (json.errors?.length) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
// The messages are untrusted text: cleaned and bounded, like the detail of
|
|
47
|
+
// a non-2xx response (#356). `data` is returned as received.
|
|
48
|
+
if (!json.data) throw new Error(`GraphQL errors: ${describeGraphqlErrors(json.errors)}`);
|
|
46
49
|
// Partial result — return data AND surface errors as a warning
|
|
47
|
-
return { data: json.data, warnings: json.errors.map((e) => e.message) };
|
|
50
|
+
return { data: json.data, warnings: cleanGraphqlErrors(json.errors).map((e) => e.message) };
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
return { data: json.data };
|
|
@@ -90,6 +93,8 @@ async function introspectGraphql({ site: siteName, typeName }) {
|
|
|
90
93
|
}
|
|
91
94
|
`;
|
|
92
95
|
const json = await drupalGraphqlFetch(site, { query, variables: { name: typeName } });
|
|
96
|
+
// A failed lookup is not a missing type: report what the server said.
|
|
97
|
+
if (!json.data?.__type && json.errors?.length) throw new Error(describeGraphqlErrors(json.errors));
|
|
93
98
|
if (!json.data?.__type) throw new Error(`Type '${typeName}' not found in schema.`);
|
|
94
99
|
return json.data.__type;
|
|
95
100
|
}
|
|
@@ -112,7 +117,7 @@ async function introspectGraphql({ site: siteName, typeName }) {
|
|
|
112
117
|
`;
|
|
113
118
|
const json = await drupalGraphqlFetch(site, { query });
|
|
114
119
|
if (json.errors?.length) {
|
|
115
|
-
throw new Error(json.errors
|
|
120
|
+
throw new Error(describeGraphqlErrors(json.errors));
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
const schema = json.data.__schema;
|
package/src/tools/nodes.js
CHANGED
|
@@ -18,6 +18,7 @@ import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omit
|
|
|
18
18
|
import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity } from "../lib/err-relationships.js";
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
+
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
21
22
|
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/sentinel-draft.js";
|
|
22
23
|
import { paragraphResourceVersion } from "./paragraphs.js";
|
|
23
24
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
@@ -412,7 +413,10 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
412
413
|
assertPublishAllowed(sec, attributes);
|
|
413
414
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
414
415
|
if (dryRun) {
|
|
415
|
-
const preview = {
|
|
416
|
+
const preview = {
|
|
417
|
+
dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships,
|
|
418
|
+
...dryRunChecks({ operation: "create", preflight: PREFLIGHT_NONE }),
|
|
419
|
+
};
|
|
416
420
|
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
417
421
|
}
|
|
418
422
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
@@ -497,6 +501,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
497
501
|
const preview = {
|
|
498
502
|
dryRun: true, operation: "update", entityType: "node", bundle: type, id,
|
|
499
503
|
attributes, relationships: resolvedRelationships,
|
|
504
|
+
...dryRunChecks({ operation: "update", preflight: patchTarget.preflight }),
|
|
500
505
|
};
|
|
501
506
|
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
|
|
502
507
|
}
|
|
@@ -556,7 +561,12 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
|
556
561
|
const site = getSiteConfig(siteName);
|
|
557
562
|
const sec = resolveSecurityConfig(site);
|
|
558
563
|
assertDeleteAllowed(sec, "node", type, id);
|
|
559
|
-
if (dryRun)
|
|
564
|
+
if (dryRun) {
|
|
565
|
+
return {
|
|
566
|
+
dryRun: true, operation: "delete", entityType: "node", bundle: type, id,
|
|
567
|
+
...dryRunChecks({ operation: "delete", preflight: PREFLIGHT_NONE }),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
560
570
|
const backend = await resolveBackend(site);
|
|
561
571
|
await backend.deleteEntity({ entityType: "node", bundle: type, id });
|
|
562
572
|
return { success: true, deletedId: id };
|
|
@@ -627,14 +637,14 @@ export const definitions = [
|
|
|
627
637
|
moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
|
|
628
638
|
fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal 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. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
|
|
629
639
|
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value: { field_resource_type: { data: { type: 'taxonomy_term--resource_type', id: '<uuid>' } } }. Multi-value: { field_tags: { data: [{ type: 'taxonomy_term--tags', id: '<uuid>' }] } }." },
|
|
630
|
-
dryRun: { type: "boolean", default: false, description: "
|
|
640
|
+
dryRun: { type: "boolean", default: false, description: "Return a preview of the payload without committing. Drupal does not evaluate the write: entity access, field access and entity validation are NOT checked, so the real create can still fail with a 403 or a validation 422. The result's `checks` block and `caveat` say what was and was not checked." },
|
|
631
641
|
returning: RETURNING_SCHEMA,
|
|
632
642
|
},
|
|
633
643
|
},
|
|
634
644
|
},
|
|
635
645
|
{
|
|
636
646
|
name: "drupal_update_node",
|
|
637
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
|
|
647
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
|
|
638
648
|
inputSchema: {
|
|
639
649
|
type: "object", required: ["type", "id"],
|
|
640
650
|
properties: {
|
|
@@ -650,7 +660,7 @@ export const definitions = [
|
|
|
650
660
|
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." },
|
|
651
661
|
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." },
|
|
652
662
|
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." },
|
|
653
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers,
|
|
663
|
+
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 published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
|
|
654
664
|
returning: RETURNING_SCHEMA,
|
|
655
665
|
},
|
|
656
666
|
},
|
|
@@ -664,7 +674,7 @@ export const definitions = [
|
|
|
664
674
|
site: { type: "string" },
|
|
665
675
|
type: { type: "string" },
|
|
666
676
|
id: { type: "string", description: "Node UUID" },
|
|
667
|
-
dryRun: { type: "boolean", default: false, description: "
|
|
677
|
+
dryRun: { type: "boolean", default: false, description: "Return a preview of the delete without committing. Drupal does not evaluate the delete: Drupal's delete access for the entity is NOT checked, only the connector's own policy. The result's `checks` block says so." },
|
|
668
678
|
},
|
|
669
679
|
},
|
|
670
680
|
},
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
* @param {number|string|null|undefined} [revisionId] Current revision id.
|
|
58
58
|
* @returns {{type: string, id: string, meta?: {target_revision_id: number}}}
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
function embedRef(bundle, id, revisionId) {
|
|
61
61
|
return embedParagraphRef(bundle, id, revisionId);
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { getSiteConfig } from "../lib/config.js";
|
|
21
21
|
import { resolveSecurityConfig, assertConfigReadAllowed } from "../lib/security.js";
|
|
22
22
|
import { gatedReport } from "../lib/reports-support.js";
|
|
23
|
-
import {
|
|
23
|
+
import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
|
|
24
24
|
import { runPrivileged, serverToolsConfigured, drushConfigured } from "../lib/audit-sources.js";
|
|
25
25
|
import { sshDrush, parseDrush } from "./drush.js";
|
|
26
26
|
|
|
@@ -71,7 +71,7 @@ async function readConfig(site, name) {
|
|
|
71
71
|
}));
|
|
72
72
|
}
|
|
73
73
|
if (serverToolsConfigured(site)) {
|
|
74
|
-
try { return toolResultData(await
|
|
74
|
+
try { return toolResultData(await callGovernedServerTool(site, "configGet", { name })); }
|
|
75
75
|
catch (err) { if (!drushConfigured(site)) throw err; }
|
|
76
76
|
}
|
|
77
77
|
return parseDrush(await sshDrush(site, ["config:get", name, "--format=json"]));
|
|
@@ -93,7 +93,7 @@ async function listConfigNames(site, prefix) {
|
|
|
93
93
|
})));
|
|
94
94
|
}
|
|
95
95
|
if (serverToolsConfigured(site)) {
|
|
96
|
-
try { return pickConfigNames(toolResultData(await
|
|
96
|
+
try { return pickConfigNames(toolResultData(await callGovernedServerTool(site, "configList", { prefix }))); }
|
|
97
97
|
catch (err) { if (!drushConfigured(site)) throw err; }
|
|
98
98
|
}
|
|
99
99
|
const out = await sshDrush(site, ["sql:query", `SELECT name FROM config WHERE collection = '' AND name LIKE '${prefix}%'`]);
|