drupal-mcp-connector 2.7.4 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
  3. package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
  6. package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
  7. package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
  8. package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
  9. package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
  10. package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
  11. package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
  13. package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
  16. package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
  18. package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
  19. package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
  20. package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
  21. package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
  23. package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
  24. package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
  25. package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
  26. package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
  27. package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
  28. package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
  29. package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
  30. package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
  31. package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
  33. package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
  34. package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
  35. package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
  36. package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
  37. package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
  39. package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
  40. package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
  43. package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
  44. package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
  45. package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
  46. package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
  49. package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
  50. package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
  51. package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
  52. package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
  54. package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
  55. package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
  57. package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
  58. package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
  59. package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
  60. package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
  62. package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
  65. package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
  68. package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
  69. package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
  70. package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
  71. package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
  72. package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
  73. package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
  74. package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
  76. package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
  77. package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
  78. package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
  79. package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
  80. package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
  81. package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
  82. package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
  84. package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
  85. package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
  87. package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
  88. package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
  89. package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
  90. package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
  91. package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
  92. package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
  93. package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
  95. package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
  96. package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
  97. package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
  98. package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
  99. package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
  100. package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
  101. package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
  102. package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
  103. package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
  107. package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
  108. package/{.claude → .agents}/commands/drupal-search.md +3 -4
  109. package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
  110. package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
  111. package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
  112. package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
  114. package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
  117. package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
  118. package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
  121. package/CHANGELOG.md +56 -0
  122. package/README.md +21 -11
  123. package/config/config.example.json +1 -1
  124. package/package.json +4 -2
  125. package/scripts/generate-commands.js +42 -21
  126. package/scripts/install-commands.js +157 -0
  127. package/src/lib/backends/backend-interface.js +20 -1
  128. package/src/lib/backends/jsonapi.js +47 -4
  129. package/src/lib/body-summary.js +60 -0
  130. package/src/lib/data-flow.js +437 -0
  131. package/src/lib/dispatch.js +36 -15
  132. package/src/lib/drupal-fetch.js +52 -6
  133. package/src/lib/field-definition.js +285 -0
  134. package/src/lib/patch-preflight.js +157 -66
  135. package/src/lib/security.js +14 -0
  136. package/src/lib/server-tools.js +9 -0
  137. package/src/lib/write-revision.js +82 -2
  138. package/src/tools/bulk.js +4 -3
  139. package/src/tools/entities.js +11 -7
  140. package/src/tools/index.js +3 -2
  141. package/src/tools/nodes.js +55 -43
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Authoritative text-format resolution for node writes (#168).
3
+ *
4
+ * `backend.getEntitySchema` is sampling-only: it can label a field
5
+ * `text_formatted` / `text_with_summary` but does NOT expose Field API
6
+ * `allowed_formats`. Never invent that list from `defaultTextFormat` or
7
+ * `FALLBACK_TEXT_FORMAT` (`full_html`).
8
+ *
9
+ * Resolution chain for a field definition:
10
+ * 1. `backend.getFieldDefinition` — JSON:API `field_config` via an internal
11
+ * adapter fetch (not `drupal_entity_get`; `field_config` is on the agent
12
+ * deny list).
13
+ * 2. Drush `config:get field.field.{entityType}.{bundle}.{field}` when a
14
+ * Drush bridge is configured.
15
+ * 3. If the definition cannot be resolved: return null. Callers keep the
16
+ * historical default chain only while the list is unknown. Once the list
17
+ * is known, never persist a format outside it.
18
+ */
19
+
20
+ import { drushConfigured } from "./audit-sources.js";
21
+ import { validateMachineName } from "./validate.js";
22
+ import { parseDrush, sshDrush } from "../tools/drush.js";
23
+
24
+ /** Last-resort body format when the Field API list cannot be resolved. */
25
+ export const FALLBACK_TEXT_FORMAT = "full_html";
26
+
27
+ const FORMATTED_FIELD_TYPES = new Set(["text", "text_long", "text_with_summary"]);
28
+
29
+ const SKIP_FORMAT_FIELDS = new Set([
30
+ "title", "status", "moderation_state", "path", "promote", "sticky",
31
+ "created", "changed", "langcode",
32
+ ]);
33
+
34
+ /**
35
+ * Normalize a Field API `allowed_formats` value to a string list.
36
+ * @param {*} raw settings.allowed_formats from field_config / Drush.
37
+ * @returns {string[]}
38
+ */
39
+ export function asFormatList(raw) {
40
+ if (Array.isArray(raw)) return raw.filter((f) => typeof f === "string" && f);
41
+ if (raw && typeof raw === "object") {
42
+ return [...new Map(Object.entries(raw)).values()].filter((f) => typeof f === "string" && f);
43
+ }
44
+ return [];
45
+ }
46
+
47
+ /**
48
+ * Parse a field_config resource or `field.field.*` config object.
49
+ * @param {object} obj Config / JSON:API attributes.
50
+ * @param {string} [fallbackName] Field name when the object omits `field_name`.
51
+ * @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
52
+ */
53
+ export function parseFieldConfigObject(obj, fallbackName) {
54
+ if (!obj || typeof obj !== "object") return null;
55
+ const attrs = new Map(Object.entries(obj));
56
+ const fieldName = attrs.get("field_name") || fallbackName;
57
+ if (typeof fieldName !== "string" || !fieldName) return null;
58
+ const settings = attrs.get("settings");
59
+ const settingsMap = settings && typeof settings === "object" && !Array.isArray(settings)
60
+ ? new Map(Object.entries(settings))
61
+ : new Map();
62
+ const fieldType = attrs.get("field_type");
63
+ return {
64
+ fieldName,
65
+ fieldType: typeof fieldType === "string" ? fieldType : null,
66
+ allowedFormats: asFormatList(settingsMap.get("allowed_formats")),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Pick the config object out of a Drush `config:get --format=json` payload.
72
+ * @param {*} raw Parsed Drush JSON.
73
+ * @param {string} configName `field.field.{type}.{bundle}.{field}`.
74
+ * @returns {?object}
75
+ */
76
+ function unwrapDrushConfig(raw, configName) {
77
+ if (!raw || typeof raw !== "object") return null;
78
+ const map = new Map(Object.entries(raw));
79
+ if (map.has("field_type") || map.has("settings")) return raw;
80
+ const named = map.get(configName);
81
+ if (named && typeof named === "object") return named;
82
+ if (map.size === 1) {
83
+ const only = [...map.values()][0];
84
+ if (only && typeof only === "object") return only;
85
+ }
86
+ return raw;
87
+ }
88
+
89
+ /**
90
+ * Load a field definition via Drush `config:get` (chain step 2).
91
+ * @param {object} site Site config with `drushSsh`.
92
+ * @param {string} entityType
93
+ * @param {string} bundle
94
+ * @param {string} fieldName
95
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
96
+ */
97
+ export async function fieldDefinitionFromDrush(site, entityType, bundle, fieldName) {
98
+ validateMachineName(entityType, "entityType");
99
+ validateMachineName(bundle, "bundle");
100
+ validateMachineName(fieldName, "fieldName");
101
+ const configName = `field.field.${entityType}.${bundle}.${fieldName}`;
102
+ try {
103
+ const raw = parseDrush(await sshDrush(site, ["config:get", configName, "--format=json"]));
104
+ return parseFieldConfigObject(unwrapDrushConfig(raw, configName), fieldName);
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve Field API metadata. See the file header for the chain.
112
+ * @param {object} backend Resolved backend.
113
+ * @param {object} site Site config.
114
+ * @param {string} entityType
115
+ * @param {string} bundle
116
+ * @param {string} fieldName
117
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
118
+ */
119
+ export async function resolveFieldDefinition(backend, site, entityType, bundle, fieldName) {
120
+ if (typeof backend?.getFieldDefinition === "function") {
121
+ try {
122
+ const def = await backend.getFieldDefinition({ entityType, bundle, fieldName });
123
+ if (def) return parseFieldConfigObject({
124
+ field_name: def.fieldName,
125
+ field_type: def.fieldType,
126
+ settings: { allowed_formats: def.allowedFormats },
127
+ }, fieldName) ?? def;
128
+ } catch {
129
+ // JSON:API field_config is optional; try Drush next.
130
+ }
131
+ }
132
+ if (drushConfigured(site)) {
133
+ const def = await fieldDefinitionFromDrush(site, entityType, bundle, fieldName);
134
+ if (def) return def;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Choose the format to persist, or throw before mutation.
141
+ *
142
+ * Known list + exactly one entry → that format when the caller omits one.
143
+ * Known list + caller format outside it → refuse.
144
+ * Known list + several entries + omitted format → site `defaultTextFormat`
145
+ * only if it is in the list; otherwise refuse (never `full_html` when it
146
+ * is not allowed).
147
+ * Unknown list (`allowedFormats` is null): historical default chain only
148
+ * when `defaultWhenUnknown` is true (body); otherwise leave format omitted.
149
+ *
150
+ * @param {{fieldName: string, requested: ?string, allowedFormats: ?string[],
151
+ * site?: object, defaultWhenUnknown?: boolean}} input
152
+ * @returns {string|undefined}
153
+ */
154
+ export function resolveTextFormat({
155
+ fieldName, requested, allowedFormats, site, defaultWhenUnknown = false,
156
+ }) {
157
+ const asked = requested === undefined || requested === null || requested === ""
158
+ ? undefined
159
+ : String(requested);
160
+
161
+ if (!Array.isArray(allowedFormats)) {
162
+ if (asked !== undefined) return asked;
163
+ if (!defaultWhenUnknown) return undefined;
164
+ return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
165
+ }
166
+
167
+ if (allowedFormats.length === 0) {
168
+ if (asked !== undefined) return asked;
169
+ if (!defaultWhenUnknown) return undefined;
170
+ return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
171
+ }
172
+
173
+ if (asked !== undefined) {
174
+ if (!allowedFormats.includes(asked)) {
175
+ throw textFormatError(fieldName, asked, allowedFormats);
176
+ }
177
+ return asked;
178
+ }
179
+
180
+ if (allowedFormats.length === 1) {
181
+ return allowedFormats[0];
182
+ }
183
+
184
+ const siteDefault = site?.defaultTextFormat;
185
+ if (siteDefault && allowedFormats.includes(siteDefault)) {
186
+ return siteDefault;
187
+ }
188
+
189
+ throw textFormatError(fieldName, siteDefault ?? FALLBACK_TEXT_FORMAT, allowedFormats);
190
+ }
191
+
192
+ /**
193
+ * @param {string} fieldName
194
+ * @param {string} requested
195
+ * @param {string[]} allowed
196
+ * @returns {Error}
197
+ */
198
+ function textFormatError(fieldName, requested, allowed) {
199
+ return new Error(
200
+ `Field "${fieldName}" does not allow format "${requested}" ` +
201
+ `(allowed: ${allowed.join(", ")}).`
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Whether a payload value is already a formatted-text object.
207
+ * @param {*} value
208
+ * @returns {boolean}
209
+ */
210
+ function isFormattedShape(value) {
211
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
212
+ if (!Object.prototype.hasOwnProperty.call(value, "value")) return false;
213
+ return ["format", "summary", "processed"].some((k) => Object.prototype.hasOwnProperty.call(value, k));
214
+ }
215
+
216
+ /**
217
+ * @param {*} value
218
+ * @returns {string|undefined}
219
+ */
220
+ function requestedFormatOf(value) {
221
+ if (value && typeof value === "object" && !Array.isArray(value)
222
+ && Object.prototype.hasOwnProperty.call(value, "format")) {
223
+ return value.format;
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ /**
229
+ * @param {*} raw
230
+ * @param {string|undefined} format
231
+ * @returns {{value: *, format?: string, summary?: *}}
232
+ */
233
+ function normalizeFormattedValue(raw, format) {
234
+ if (typeof raw === "string") {
235
+ const out = { value: raw };
236
+ if (format !== undefined) out.format = format;
237
+ return out;
238
+ }
239
+ const out = { value: raw.value };
240
+ if (format !== undefined) out.format = format;
241
+ if (Object.prototype.hasOwnProperty.call(raw, "summary")) out.summary = raw.summary;
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Default and validate text formats on a write attribute map (mutates it).
247
+ * Same checks run for dry-run and real writes so a disallowed format never
248
+ * reaches create/update.
249
+ *
250
+ * @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object}} input
251
+ * @returns {Promise<object>} The same attributes object, with formats resolved.
252
+ */
253
+ export async function applyAllowedFormatsToAttributes({
254
+ backend, site, entityType, bundle, attributes,
255
+ }) {
256
+ const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
257
+ for (const fieldName of names) {
258
+ const value = new Map(Object.entries(attributes)).get(fieldName);
259
+ if (value === undefined || value === null) continue;
260
+ if (typeof value !== "string" && (typeof value !== "object" || Array.isArray(value))) continue;
261
+
262
+ const def = await resolveFieldDefinition(backend, site, entityType, bundle, fieldName);
263
+ const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
264
+ const restricted = Boolean(def?.allowedFormats?.length);
265
+ const isBody = fieldName === "body";
266
+ const shaped = isFormattedShape(value);
267
+
268
+ if (def && !formattedType && !restricted && !isBody) continue;
269
+ if (!def && !shaped && !isBody) continue;
270
+
271
+ const format = resolveTextFormat({
272
+ fieldName,
273
+ requested: requestedFormatOf(value),
274
+ allowedFormats: def ? def.allowedFormats : null,
275
+ site,
276
+ defaultWhenUnknown: isBody,
277
+ });
278
+
279
+ if (format === undefined && !formattedType && !isBody && !shaped) continue;
280
+ Object.assign(attributes, Object.fromEntries([
281
+ [fieldName, normalizeFormattedValue(value, format)],
282
+ ]));
283
+ }
284
+ return attributes;
285
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSON:API PATCH preflight for Drupal core's working-copy guard (#201).
2
+ * JSON:API PATCH preflight and working-copy targeting (#201 / #166).
3
3
  *
4
4
  * `EntityResource::patchIndividual()` rejects a canonical PATCH when the
5
5
  * stored entity is not both the latest and the default revision. That check
@@ -8,23 +8,33 @@
8
8
  * often `setNewRevision`) — so the probe must fail *after* the guard and
9
9
  * *before* save. Core next compares `data.id` to the URL entity UUID; a
10
10
  * well-formed but non-matching id yields 400 "does not match the ID in the
11
- * payload" with no row written. Content-moderation's `rel:latest-version` /
12
- * `rel:working-copy` aliases can disagree with storage (a revision row with
13
- * no `content_moderation_state`), which is how every read tool reports
14
- * clean and the write then 400s.
11
+ * payload" with no row written.
15
12
  *
16
- * `workingCopy: null` and "latest-version vid === default vid" are not proof
17
- * the node is writable. That is the #201 lie.
13
+ * Two distinct cases share that core 400:
18
14
  *
19
- * Distinct from #166: there a working copy is visible and the fix is PATCH
20
- * `?resourceVersion=rel:working-copy`. This module does not implement that.
15
+ * - **#166** `rel:working-copy` resolves. PATCH (and the dryRun probe)
16
+ * target `?resourceVersion=rel:working-copy`. Do not discard, do not
17
+ * retry the canonical URL, and do not tell the caller to publish first.
18
+ * - **#201** — the working-copy alias does not resolve, but core still
19
+ * blocks. That is a stray revision row. Refuse with revision-surgery
20
+ * language. `workingCopy: null` is not proof the node is writable.
21
+ *
22
+ * A working-copy 400 *after* targeting the working copy is a stale or
23
+ * concurrent write — refuse, do not fall back to discard.
21
24
  */
22
25
 
23
26
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
27
+ import { entityRevisionId } from "./write-revision.js";
24
28
 
25
29
  /** Stable error code for a core working-copy / not-latest-revision block. */
26
30
  export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
27
31
 
32
+ /** Stable error code when a working-copy PATCH itself is rejected. */
33
+ export const PATCH_WORKING_COPY_STALE_CODE = "PATCH_WORKING_COPY_STALE";
34
+
35
+ /** Stable error code when the working-copy resource does not match the target. */
36
+ export const PATCH_TARGET_AMBIGUOUS_CODE = "PATCH_TARGET_AMBIGUOUS";
37
+
28
38
  const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
29
39
 
30
40
  /**
@@ -39,61 +49,75 @@ export const PATCH_BLOCKED_MESSAGE =
39
49
  "See connector #201. Do not retry the same canonical PATCH.";
40
50
 
41
51
  /**
42
- * Read a revision id off a working-copy body (canonical or raw-ish).
43
- * @param {?object} workingCopy
44
- * @returns {?number|string}
45
- */
46
- function workingCopyVid(workingCopy) {
47
- if (!workingCopy || typeof workingCopy !== "object") return null;
48
- const fields = workingCopy.fields && typeof workingCopy.fields === "object"
49
- ? workingCopy.fields
50
- : {};
51
- const attrs = workingCopy.attributes && typeof workingCopy.attributes === "object"
52
- ? workingCopy.attributes
53
- : {};
54
- const raw = workingCopy.vid
55
- ?? fields.drupal_internal__vid
56
- ?? attrs.drupal_internal__vid
57
- ?? workingCopy.drupal_internal__vid;
58
- if (raw === undefined || raw === null || raw === "") return null;
59
- const n = Number(raw);
60
- return Number.isFinite(n) ? n : raw;
61
- }
52
+ * A working-copy-targeted PATCH (or its probe) hit core's working-copy guard.
53
+ * That is a stale or concurrent conflict, not an invitation to discard.
54
+ */
55
+ export const PATCH_WORKING_COPY_STALE_MESSAGE =
56
+ "This entity's working-copy revision could not be updated (stale or concurrent write). " +
57
+ "The connector will not retry the canonical URL or discard the draft. " +
58
+ "Re-read rel:working-copy and retry, or resolve the conflict in Drupal. See connector #166.";
62
59
 
63
60
  /**
64
61
  * Operator message for a core working-copy 400.
65
- * A resolvable working copy is an ordinary pending draftdo not prescribe
66
- * revision surgery. Surgery is only for the invisible-row case (#201 follow-up).
67
- * @param {?object} [workingCopy]
62
+ * A resolvable working copy is edited in place (#166)this message is only
63
+ * for the invisible-row case (#201).
68
64
  * @returns {string}
69
65
  */
70
- export function patchBlockedMessage(workingCopy) {
71
- if (workingCopy) {
72
- const vid = workingCopyVid(workingCopy);
73
- const which = vid !== null && vid !== undefined ? ` (vid ${vid})` : "";
74
- return `This node has a pending draft${which}. Publish or discard it `
75
- + "before a canonical PATCH.";
76
- }
66
+ export function patchBlockedMessage() {
77
67
  return PATCH_BLOCKED_MESSAGE;
78
68
  }
79
69
 
80
70
  /**
81
- * Thrown when the core working-copy PATCH guard rejects a write (or its probe).
71
+ * Thrown when the core working-copy PATCH guard rejects a canonical write
72
+ * (or its probe) and no working copy is addressable (#201).
82
73
  */
83
74
  export class PatchBlockedError extends Error {
84
75
  /**
85
76
  * @param {?Error} [cause] The original Drupal 400.
86
- * @param {{workingCopy?: ?object}} [options]
87
77
  */
88
- constructor(cause, { workingCopy } = {}) {
89
- super(patchBlockedMessage(workingCopy ?? null));
78
+ constructor(cause) {
79
+ super(PATCH_BLOCKED_MESSAGE);
90
80
  this.name = "PatchBlockedError";
91
81
  this.code = PATCH_BLOCKED_CODE;
92
- if (workingCopy) this.workingCopyVid = workingCopyVid(workingCopy);
93
82
  if (cause) this.cause = cause;
94
83
  }
95
84
  }
96
85
 
86
+ /**
87
+ * Thrown when a working-copy-targeted PATCH or probe is rejected (#166).
88
+ */
89
+ export class WorkingCopyStaleError extends Error {
90
+ /**
91
+ * @param {?Error} [cause] The original Drupal 400.
92
+ */
93
+ constructor(cause) {
94
+ super(PATCH_WORKING_COPY_STALE_MESSAGE);
95
+ this.name = "WorkingCopyStaleError";
96
+ this.code = PATCH_WORKING_COPY_STALE_CODE;
97
+ if (cause) this.cause = cause;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Thrown when `rel:working-copy` resolves to a different UUID than the
103
+ * entity being updated.
104
+ */
105
+ export class PatchTargetAmbiguousError extends Error {
106
+ /**
107
+ * @param {string} expectedId Requested entity UUID.
108
+ * @param {string} actualId Working-copy resource UUID.
109
+ */
110
+ constructor(expectedId, actualId) {
111
+ super(
112
+ `The working-copy resource id (${actualId}) does not match the entity ` +
113
+ `being updated (${expectedId}). Refusing an ambiguous PATCH target. ` +
114
+ "See connector #166."
115
+ );
116
+ this.name = "PatchTargetAmbiguousError";
117
+ this.code = PATCH_TARGET_AMBIGUOUS_CODE;
118
+ }
119
+ }
120
+
97
121
  /**
98
122
  * Whether an error is Drupal core's working-copy PATCH guard (core #2795279).
99
123
  * @param {unknown} err
@@ -105,27 +129,22 @@ export function isWorkingCopyPatchError(err) {
105
129
 
106
130
  /**
107
131
  * Rewrite a core working-copy 400 into {@link PatchBlockedError}; otherwise
108
- * return the original value. Pass `workingCopy` when the pending draft is
109
- * addressable so the message does not prescribe revision surgery.
132
+ * return the original value. Used only for the stray-revision (#201) case.
110
133
  * @param {unknown} err
111
- * @param {{workingCopy?: ?object}} [options]
112
134
  * @returns {unknown}
113
135
  */
114
- export function rewriteWorkingCopyPatchError(err, { workingCopy } = {}) {
136
+ export function rewriteWorkingCopyPatchError(err) {
115
137
  if (!isWorkingCopyPatchError(err)) return err;
116
- return new PatchBlockedError(
117
- err instanceof Error ? err : new Error(String(err)),
118
- { workingCopy },
119
- );
138
+ return new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
120
139
  }
121
140
 
122
141
  /**
123
- * Load `rel:working-copy` so a blocked PATCH can name a pending draft.
142
+ * Load `rel:working-copy` so a write can target the pending draft (#166).
124
143
  * @param {object} backend
125
144
  * @param {{entityType: string, bundle: string, id: string}} ref
126
145
  * @returns {Promise<?object>}
127
146
  */
128
- async function loadWorkingCopy(backend, { entityType, bundle, id }) {
147
+ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
129
148
  if (typeof backend?.getEntity !== "function") return null;
130
149
  try {
131
150
  const wc = await backend.getEntity({
@@ -137,6 +156,38 @@ async function loadWorkingCopy(backend, { entityType, bundle, id }) {
137
156
  }
138
157
  }
139
158
 
159
+ /**
160
+ * Decide whether this PATCH should target `rel:working-copy`.
161
+ *
162
+ * A 200 from the working-copy alias is treated as an addressable draft unless
163
+ * both live and working vids are known and equal (the alias echoed the
164
+ * default revision). An id mismatch is refused rather than guessed.
165
+ *
166
+ * @param {object} backend
167
+ * @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
168
+ * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
169
+ * @throws {PatchTargetAmbiguousError}
170
+ */
171
+ export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
172
+ const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
173
+ let liveVid = entityRevisionId(existing);
174
+ if (workingCopy && (liveVid === null || liveVid === undefined) && typeof backend?.getEntity === "function") {
175
+ const live = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
176
+ liveVid = entityRevisionId(live);
177
+ }
178
+ if (!workingCopy) {
179
+ return { resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null };
180
+ }
181
+ if (workingCopy.id && workingCopy.id !== id) {
182
+ throw new PatchTargetAmbiguousError(id, workingCopy.id);
183
+ }
184
+ const workingVid = entityRevisionId(workingCopy);
185
+ if (workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid)) {
186
+ return { resourceVersion: undefined, workingCopy, liveVid, workingVid };
187
+ }
188
+ return { resourceVersion: "rel:working-copy", workingCopy, liveVid, workingVid };
189
+ }
190
+
140
191
  /**
141
192
  * Whether this update should run the PATCH probe.
142
193
  * Skip unmoderated / non-revisionable bundles — the guard is about
@@ -170,14 +221,16 @@ export function isProbePassedWithoutSave(err) {
170
221
  }
171
222
 
172
223
  /**
173
- * Probe the same guard core uses on the canonical PATCH URL.
224
+ * Probe the same guard core uses on the PATCH about to be sent.
174
225
  *
175
226
  * Sends a PATCH whose `data.id` does not match the URL entity. Core runs
176
227
  * the working-copy check first; a match on that phrase means no row was
177
228
  * written. An id-mismatch 400 (or deserialize 422) means the guard passed
178
229
  * and save was not reached. A 2xx would have saved a revision and is
179
- * treated as a probe failure. Do not treat "latest-version vid === default
180
- * vid" as writable.
230
+ * treated as a probe failure.
231
+ *
232
+ * When `resourceVersion` is `rel:working-copy`, the probe hits that same
233
+ * URL so dryRun cannot succeed when the real write would 400 (#166).
181
234
  *
182
235
  * @param {object} args
183
236
  * @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
@@ -186,11 +239,12 @@ export function isProbePassedWithoutSave(err) {
186
239
  * @param {string} args.id
187
240
  * @param {?object} [args.existing]
188
241
  * @param {object} [args.attributes]
242
+ * @param {?string} [args.resourceVersion]
189
243
  * @returns {Promise<{probed: boolean, writable?: boolean|string, skipped?: string}>}
190
- * @throws {PatchBlockedError} When the guard rejects the probe.
244
+ * @throws {PatchBlockedError|WorkingCopyStaleError} When the guard rejects the probe.
191
245
  */
192
246
  export async function preflightPatchWritable({
193
- backend, entityType, bundle, id, existing, attributes,
247
+ backend, entityType, bundle, id, existing, attributes, resourceVersion,
194
248
  }) {
195
249
  if (!shouldPreflightPatch({ existing, attributes })) {
196
250
  return { probed: false };
@@ -198,7 +252,10 @@ export async function preflightPatchWritable({
198
252
  if (typeof backend?.rawQuery !== "function" || typeof backend?.resourcePath !== "function") {
199
253
  return { probed: false, skipped: "backend cannot issue a raw PATCH probe" };
200
254
  }
201
- const path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
255
+ let path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
256
+ if (resourceVersion) {
257
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
258
+ }
202
259
  const type = `${entityType}--${bundle}`;
203
260
  const probeId = id === PATCH_PROBE_MISMATCH_ID
204
261
  ? "00000000-0000-4000-a000-000000000002"
@@ -217,11 +274,15 @@ export async function preflightPatchWritable({
217
274
  );
218
275
  } catch (err) {
219
276
  if (isWorkingCopyPatchError(err)) {
277
+ const cause = err instanceof Error ? err : new Error(String(err));
278
+ if (resourceVersion === "rel:working-copy") {
279
+ throw new WorkingCopyStaleError(cause);
280
+ }
220
281
  const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
221
- throw new PatchBlockedError(
222
- err instanceof Error ? err : new Error(String(err)),
223
- { workingCopy },
224
- );
282
+ if (workingCopy) {
283
+ throw new WorkingCopyStaleError(cause);
284
+ }
285
+ throw new PatchBlockedError(cause);
225
286
  }
226
287
  if (isProbePassedWithoutSave(err)) {
227
288
  return { probed: true, writable: true };
@@ -230,19 +291,49 @@ export async function preflightPatchWritable({
230
291
  }
231
292
  }
232
293
 
294
+ /**
295
+ * Resolve the PATCH target, then run the same probe the real write will use.
296
+ * Callers inherit #166 targeting by going through this before dryRun or write.
297
+ *
298
+ * @param {object} backend
299
+ * @param {{entityType: string, bundle: string, id: string, existing?: ?object, attributes?: object}} args
300
+ * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
301
+ */
302
+ export async function prepareGuardedPatch(backend, {
303
+ entityType, bundle, id, existing, attributes,
304
+ }) {
305
+ const target = shouldPreflightPatch({ existing, attributes })
306
+ ? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
307
+ : { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
308
+ await preflightPatchWritable({
309
+ backend, entityType, bundle, id, existing, attributes,
310
+ resourceVersion: target.resourceVersion,
311
+ });
312
+ return target;
313
+ }
314
+
233
315
  /**
234
316
  * `backend.updateEntity` with the core working-copy 400 rewritten.
317
+ * A working-copy-targeted 400 is stale/concurrent (#166). A canonical 400
318
+ * with no addressable working copy is the #201 stray-revision case.
235
319
  * @param {object} backend
236
320
  * @param {object} input updateEntity argument.
237
321
  * @returns {Promise<*>}
238
- * @throws {PatchBlockedError|*}
322
+ * @throws {PatchBlockedError|WorkingCopyStaleError|*}
239
323
  */
240
324
  export async function updateEntityGuarded(backend, input) {
241
325
  try {
242
326
  return await backend.updateEntity(input);
243
327
  } catch (err) {
244
328
  if (!isWorkingCopyPatchError(err)) throw err;
329
+ const cause = err instanceof Error ? err : new Error(String(err));
330
+ if (input?.resourceVersion === "rel:working-copy") {
331
+ throw new WorkingCopyStaleError(cause);
332
+ }
245
333
  const workingCopy = await loadWorkingCopy(backend, input);
246
- throw rewriteWorkingCopyPatchError(err, { workingCopy });
334
+ if (workingCopy) {
335
+ throw new WorkingCopyStaleError(cause);
336
+ }
337
+ throw new PatchBlockedError(cause);
247
338
  }
248
339
  }
@@ -53,6 +53,17 @@ import { parse } from "graphql";
53
53
  *
54
54
  * globalRedactedFields string[] stripped from every response, every type
55
55
  *
56
+ * declaredCeiling string narrow-only X-MCP-Declared-Ceiling
57
+ * (public|internal|restricted). Invalid
58
+ * values are dropped, never widened.
59
+ * readBudgets object finite northbound budgets bound to
60
+ * principal+target (#179). Same classes
61
+ * as mcp_sentinel: results, bytes,
62
+ * requests, requestWindowSec, pages,
63
+ * pageWindowSec, chainedActions,
64
+ * chainedActionWindowSec. Omitted keys
65
+ * use the module defaults.
66
+ *
56
67
  * ─── Field redaction ──────────────────────────────────────────────────────
57
68
  *
58
69
  * Redacted fields are replaced with "[REDACTED]" in response attributes.
@@ -259,6 +270,8 @@ export function resolveSecurityConfig(site) {
259
270
  ...(preset.globalRedactedFields ?? []),
260
271
  ...(raw.globalRedactedFields ?? []),
261
272
  ],
273
+ declaredCeiling: raw.declaredCeiling ?? preset.declaredCeiling,
274
+ readBudgets: raw.readBudgets ?? preset.readBudgets ?? null,
262
275
  };
263
276
  }
264
277
 
@@ -754,5 +767,6 @@ export function getSecuritySummary(site) {
754
767
  deniedEntityTypes: cfg.deniedEntityTypes,
755
768
  entityRules: cfg.entityRules,
756
769
  globalRedactedFields: cfg.globalRedactedFields,
770
+ declaredCeiling: cfg.declaredCeiling ?? null,
757
771
  };
758
772
  }