drupal-mcp-connector 3.1.0 → 3.2.1
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-draft-nested-components.md +26 -0
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +21 -2
- package/package.json +7 -7
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +22 -6
- package/src/lib/canonical-draft.js +795 -0
- package/src/lib/component-draft.js +24 -2
- package/src/lib/dispatch.js +7 -0
- package/src/lib/field-definition.js +146 -41
- package/src/lib/nested-draft.js +1420 -0
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +34 -0
- package/src/lib/text-format-context.js +248 -0
- package/src/lib/write-revision.js +66 -16
- package/src/tools/bulk.js +17 -12
- package/src/tools/entities.js +16 -14
- package/src/tools/index.js +2 -1
- package/src/tools/media.js +9 -2
- package/src/tools/nested.js +69 -0
- package/src/tools/nodes.js +2 -2
- package/src/tools/paragraphs.js +12 -3
- package/src/tools/translations.js +5 -1
package/src/lib/operations.js
CHANGED
|
@@ -18,7 +18,7 @@ const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
|
|
|
18
18
|
"drupal_drush_updatedb", "drupal_drush_module_enable",
|
|
19
19
|
"drupal_drush_module_disable", "drupal_drush_user_create",
|
|
20
20
|
// v1.0 feature tools that perform writes but don't start with create_/update_:
|
|
21
|
-
"drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_",
|
|
21
|
+
"drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_", "drupal_draft_",
|
|
22
22
|
// Governed config write (also gated inside the handler by the config-write cap):
|
|
23
23
|
"drupal_config_set"];
|
|
24
24
|
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
|
|
25
25
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
26
26
|
import { entityRevisionId } from "./write-revision.js";
|
|
27
|
+
import {
|
|
28
|
+
isCanonicalDraftType,
|
|
29
|
+
preflightGovernedCanonicalDraft,
|
|
30
|
+
resolveCanonicalDraftIdentity,
|
|
31
|
+
writeCanonicalModeratedEntity,
|
|
32
|
+
} from "./canonical-draft.js";
|
|
27
33
|
import { httpStatusOf } from "./error-status.js";
|
|
28
34
|
import {
|
|
29
35
|
writeDraft,
|
|
@@ -245,6 +251,20 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
|
|
|
245
251
|
* @throws {PatchTargetAmbiguousError}
|
|
246
252
|
*/
|
|
247
253
|
export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
|
|
254
|
+
if (isCanonicalDraftType(entityType)) {
|
|
255
|
+
// A readable unmoderated block or library item has no draft alias to
|
|
256
|
+
// prove. Identity resolution stays fail-closed when the entity is
|
|
257
|
+
// moderated or could not be read.
|
|
258
|
+
if (existing && !entityLooksModerated(existing)) {
|
|
259
|
+
return {
|
|
260
|
+
resourceVersion: undefined,
|
|
261
|
+
workingCopy: null,
|
|
262
|
+
liveVid: entityRevisionId(existing, entityType),
|
|
263
|
+
workingVid: null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return resolveCanonicalDraftIdentity(backend, { entityType, bundle, id, existing });
|
|
267
|
+
}
|
|
248
268
|
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
|
|
249
269
|
if (workingCopy?.id && workingCopy.id !== id) {
|
|
250
270
|
throw new PatchTargetAmbiguousError(id, workingCopy.id);
|
|
@@ -478,6 +498,17 @@ export async function prepareGuardedPatch(backend, {
|
|
|
478
498
|
target.preflight = PREFLIGHT_SENTINEL_DRAFT;
|
|
479
499
|
return target;
|
|
480
500
|
}
|
|
501
|
+
if (target.governedDraft) {
|
|
502
|
+
try {
|
|
503
|
+
await preflightGovernedCanonicalDraft(backend, {
|
|
504
|
+
entityType, bundle, id, attributes, relationships, governedDraft: target.governedDraft,
|
|
505
|
+
});
|
|
506
|
+
} catch (err) {
|
|
507
|
+
throw rewriteStaleCopyError(err);
|
|
508
|
+
}
|
|
509
|
+
target.preflight = PREFLIGHT_SENTINEL_DRAFT;
|
|
510
|
+
return target;
|
|
511
|
+
}
|
|
481
512
|
if (target.resourceVersion) {
|
|
482
513
|
const draftRevisionFor = (lang) => ({
|
|
483
514
|
liveVid: target.liveVid,
|
|
@@ -584,6 +615,9 @@ function explainDefaultLanguageRefusal(err, inferredLangcode) {
|
|
|
584
615
|
* @throws {PatchBlockedError|WorkingCopyStaleError|*}
|
|
585
616
|
*/
|
|
586
617
|
export async function updateEntityGuarded(backend, input) {
|
|
618
|
+
if (isCanonicalDraftType(input?.entityType) && !input?.draftRevision && !input?.resourceVersion) {
|
|
619
|
+
return writeCanonicalModeratedEntity(backend, input);
|
|
620
|
+
}
|
|
587
621
|
try {
|
|
588
622
|
if (input?.draftRevision) return await writeDraft(backend, input);
|
|
589
623
|
if (input?.resourceVersion) {
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-format restrictions from MCP Sentinel's site schema (#429).
|
|
3
|
+
*
|
|
4
|
+
* A content-tier token cannot read JSON:API `field_config` (that needs
|
|
5
|
+
* "administer node fields") and this connector's production configs often
|
|
6
|
+
* have no Drush bridge. Sentinel's `GET /drupal-mcp/context` is the schema
|
|
7
|
+
* that token can read. When a field entry includes `allowed_formats`, that
|
|
8
|
+
* list is Field API's restriction: one entry is the default, any other
|
|
9
|
+
* format is refused. A missing key means an older Sentinel that does not
|
|
10
|
+
* report the restriction — the caller must not treat that as "all formats".
|
|
11
|
+
* An empty array means the field has no format restriction.
|
|
12
|
+
*
|
|
13
|
+
* Dispatch fills the process cache before a tool that resolves text formats.
|
|
14
|
+
* A failed fetch is remembered only briefly so a Sentinel outage does not
|
|
15
|
+
* add the timeout to every later write, and concurrent writes share one
|
|
16
|
+
* request. Unit tests that call handlers directly can seed a successful
|
|
17
|
+
* document with `rememberTextFormatContext`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
21
|
+
|
|
22
|
+
/** How long a successful context document is reused. */
|
|
23
|
+
const CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/** How long a miss is remembered. Short, so a recovered Sentinel is retried. */
|
|
26
|
+
const FAILURE_TTL_MS = 30 * 1000;
|
|
27
|
+
|
|
28
|
+
/** Context fetch budget. A miss must not stall the write past this. */
|
|
29
|
+
const CONTEXT_TIMEOUT_MS = 8000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tools whose handlers call the text-format resolver. Other writes must not
|
|
33
|
+
* wait on this document.
|
|
34
|
+
*/
|
|
35
|
+
const TEXT_FORMAT_TOOLS = new Set([
|
|
36
|
+
"drupal_create_node",
|
|
37
|
+
"drupal_update_node",
|
|
38
|
+
"drupal_entity_create",
|
|
39
|
+
"drupal_entity_update",
|
|
40
|
+
"drupal_create_translation",
|
|
41
|
+
"drupal_bulk_create",
|
|
42
|
+
"drupal_bulk_update",
|
|
43
|
+
"drupal_create_paragraph",
|
|
44
|
+
"drupal_update_paragraph",
|
|
45
|
+
"drupal_create_media",
|
|
46
|
+
"drupal_update_media",
|
|
47
|
+
"drupal_draft_nested_components",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** @type {Map<string, {at: number, document: ?object, ttl: number}>} */
|
|
51
|
+
const cache = new Map();
|
|
52
|
+
|
|
53
|
+
/** @type {Map<string, {slot: object, promise: Promise<void>}>} */
|
|
54
|
+
const inflight = new Map();
|
|
55
|
+
|
|
56
|
+
/** @type {(site: object) => Promise<?object>} */
|
|
57
|
+
let fetcher = async () => null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Cache key for one configured site. Name and base URL both matter so two
|
|
61
|
+
* sites that share a name in different processes cannot collide here, and a
|
|
62
|
+
* renamed base URL does not reuse the previous document.
|
|
63
|
+
* @param {object} site Resolved site config.
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function cacheKey(site) {
|
|
67
|
+
return `${site?._name ?? ""}\0${site?.baseUrl ?? ""}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drop cached schema documents. Tests call this between cases.
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
export function clearTextFormatContextCache() {
|
|
75
|
+
cache.clear();
|
|
76
|
+
inflight.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether this tool resolves text formats and should wait for context.
|
|
81
|
+
* @param {string} toolName
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function toolNeedsTextFormatContext(toolName) {
|
|
85
|
+
return TEXT_FORMAT_TOOLS.has(toolName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Seed the cache without a network call. A seeded document uses the success TTL.
|
|
90
|
+
* @param {object} site Resolved site config.
|
|
91
|
+
* @param {object} document Sentinel context JSON.
|
|
92
|
+
* @returns {void}
|
|
93
|
+
*/
|
|
94
|
+
export function rememberTextFormatContext(site, document) {
|
|
95
|
+
cache.set(cacheKey(site), { at: Date.now(), document, ttl: CONTEXT_TTL_MS });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} key
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
function fresh(key) {
|
|
103
|
+
const hit = cache.get(key);
|
|
104
|
+
if (!hit) return false;
|
|
105
|
+
return Date.now() - hit.at < hit.ttl;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Install the process fetcher. The server entry calls this with
|
|
110
|
+
* {@link fetchTextFormatContext}. Tests leave the default, which returns
|
|
111
|
+
* null and does not touch the network.
|
|
112
|
+
* @param {(site: object) => Promise<?object>} next
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
export function installTextFormatContextFetcher(next) {
|
|
116
|
+
fetcher = next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load `GET /drupal-mcp/context` when the cache is cold. A failure is cached
|
|
121
|
+
* as a miss for 30 seconds: the write keeps the historical path
|
|
122
|
+
* for an unknown format list, and the next write does not wait again.
|
|
123
|
+
* Concurrent callers share one in-flight request.
|
|
124
|
+
* @param {object} site Resolved site config.
|
|
125
|
+
* @returns {Promise<void>}
|
|
126
|
+
*/
|
|
127
|
+
export async function ensureTextFormatContext(site) {
|
|
128
|
+
if (!site?.baseUrl) return;
|
|
129
|
+
const key = cacheKey(site);
|
|
130
|
+
if (fresh(key)) return;
|
|
131
|
+
const pending = inflight.get(key);
|
|
132
|
+
if (pending) {
|
|
133
|
+
await pending.promise;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Identity is a plain object. Comparing the promise itself is what CodeQL
|
|
137
|
+
// reports as a missing await.
|
|
138
|
+
const slot = {};
|
|
139
|
+
const promise = loadTextFormatContext(site, key);
|
|
140
|
+
inflight.set(key, { slot, promise });
|
|
141
|
+
try {
|
|
142
|
+
await promise;
|
|
143
|
+
} finally {
|
|
144
|
+
const row = inflight.get(key);
|
|
145
|
+
if (row?.slot === slot) inflight.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {object} site
|
|
151
|
+
* @param {string} key
|
|
152
|
+
* @returns {Promise<void>}
|
|
153
|
+
*/
|
|
154
|
+
async function loadTextFormatContext(site, key) {
|
|
155
|
+
let document = null;
|
|
156
|
+
try {
|
|
157
|
+
const loaded = await fetcher(site);
|
|
158
|
+
if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) document = loaded;
|
|
159
|
+
} catch {
|
|
160
|
+
document = null;
|
|
161
|
+
}
|
|
162
|
+
cache.set(key, {
|
|
163
|
+
at: Date.now(),
|
|
164
|
+
document,
|
|
165
|
+
ttl: document ? CONTEXT_TTL_MS : FAILURE_TTL_MS,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* GET /drupal-mcp/context as the site's own principal.
|
|
171
|
+
* @param {object} site Resolved site config.
|
|
172
|
+
* @returns {Promise<?object>} The JSON document, or null on any miss.
|
|
173
|
+
*/
|
|
174
|
+
export async function fetchTextFormatContext(site) {
|
|
175
|
+
let headers;
|
|
176
|
+
try {
|
|
177
|
+
headers = {
|
|
178
|
+
Accept: "application/json",
|
|
179
|
+
...clientHeaders(),
|
|
180
|
+
...(await authHeadersAsync(site)),
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
let res;
|
|
186
|
+
try {
|
|
187
|
+
res = await fetch(`${site.baseUrl}/drupal-mcp/context`, {
|
|
188
|
+
method: "GET",
|
|
189
|
+
headers,
|
|
190
|
+
signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS),
|
|
191
|
+
});
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok) return null;
|
|
196
|
+
try {
|
|
197
|
+
const body = await res.json();
|
|
198
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
199
|
+
if (!Object.prototype.hasOwnProperty.call(body, "content_types")) return null;
|
|
200
|
+
return body;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Field definition from a cached context document.
|
|
208
|
+
* Only node bundles are present. A field with no `allowed_formats` key is
|
|
209
|
+
* unknown (older Sentinel), not unrestricted.
|
|
210
|
+
* @param {object} site Resolved site config.
|
|
211
|
+
* @param {string} entityType
|
|
212
|
+
* @param {string} bundle
|
|
213
|
+
* @param {string} fieldName
|
|
214
|
+
* @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
|
|
215
|
+
*/
|
|
216
|
+
export function textFormatDefinitionFromContext(site, entityType, bundle, fieldName) {
|
|
217
|
+
if (entityType !== "node") return null;
|
|
218
|
+
const hit = cache.get(cacheKey(site));
|
|
219
|
+
// A cached miss (document null) is not a schema. Do not invent a list.
|
|
220
|
+
if (!hit?.document || !fresh(cacheKey(site))) return null;
|
|
221
|
+
const types = hit.document?.content_types;
|
|
222
|
+
if (!types || typeof types !== "object" || Array.isArray(types)) return null;
|
|
223
|
+
const schema = new Map(Object.entries(types)).get(bundle);
|
|
224
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return null;
|
|
225
|
+
const fields = new Map(Object.entries(schema)).get("fields");
|
|
226
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return null;
|
|
227
|
+
const field = new Map(Object.entries(fields)).get(fieldName);
|
|
228
|
+
if (!field || typeof field !== "object" || Array.isArray(field)) return null;
|
|
229
|
+
const attrs = new Map(Object.entries(field));
|
|
230
|
+
if (!attrs.has("allowed_formats")) return null;
|
|
231
|
+
const fieldType = attrs.get("type");
|
|
232
|
+
return {
|
|
233
|
+
fieldName,
|
|
234
|
+
fieldType: typeof fieldType === "string" ? fieldType : null,
|
|
235
|
+
allowedFormats: normalizeContextFormats(attrs.get("allowed_formats")),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param {*} raw `allowed_formats` from the context document.
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
function normalizeContextFormats(raw) {
|
|
244
|
+
const values = Array.isArray(raw)
|
|
245
|
+
? raw
|
|
246
|
+
: (raw && typeof raw === "object" ? [...new Map(Object.entries(raw)).values()] : []);
|
|
247
|
+
return values.filter((value) => typeof value === "string" && value !== "" && value !== "0");
|
|
248
|
+
}
|
|
@@ -11,27 +11,73 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* JSON:API revision attribute for entity types whose revision key is known.
|
|
15
|
+
* `taxonomy_term`'s `drupal_internal__vid` is the vocabulary id, not a revision.
|
|
16
|
+
* Node and media revisions use `vid`. Paragraphs, library items, and custom
|
|
17
|
+
* blocks use `revision_id`.
|
|
18
|
+
*/
|
|
19
|
+
const REVISION_ATTRIBUTE = {
|
|
20
|
+
node: "drupal_internal__vid",
|
|
21
|
+
media: "drupal_internal__vid",
|
|
22
|
+
paragraph: "drupal_internal__revision_id",
|
|
23
|
+
paragraphs_library_item: "drupal_internal__revision_id",
|
|
24
|
+
block_content: "drupal_internal__revision_id",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} entity
|
|
29
|
+
* @param {object} fields
|
|
30
|
+
* @param {object} attrs
|
|
31
|
+
* @param {string} key
|
|
32
|
+
* @returns {unknown}
|
|
33
|
+
*/
|
|
34
|
+
function readRevisionAttribute(entity, fields, attrs, key) {
|
|
35
|
+
if (key === "drupal_internal__vid") {
|
|
36
|
+
return entity.vid
|
|
37
|
+
?? fields.drupal_internal__vid
|
|
38
|
+
?? attrs.drupal_internal__vid
|
|
39
|
+
?? entity.drupal_internal__vid;
|
|
40
|
+
}
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(fields, key)) return fields[key];
|
|
42
|
+
if (Object.prototype.hasOwnProperty.call(attrs, key)) return attrs[key];
|
|
43
|
+
if (Object.prototype.hasOwnProperty.call(entity, key)) return entity[key];
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} raw
|
|
16
49
|
* @returns {?number|string}
|
|
17
50
|
*/
|
|
18
|
-
|
|
19
|
-
if (!entity || typeof entity !== "object") return null;
|
|
20
|
-
const fields = entity.fields && typeof entity.fields === "object"
|
|
21
|
-
? entity.fields
|
|
22
|
-
: {};
|
|
23
|
-
const attrs = entity.attributes && typeof entity.attributes === "object"
|
|
24
|
-
? entity.attributes
|
|
25
|
-
: {};
|
|
26
|
-
const raw = entity.vid
|
|
27
|
-
?? fields.drupal_internal__vid
|
|
28
|
-
?? attrs.drupal_internal__vid
|
|
29
|
-
?? entity.drupal_internal__vid;
|
|
51
|
+
function normalizeRevisionId(raw) {
|
|
30
52
|
if (raw === undefined || raw === null || raw === "") return null;
|
|
31
53
|
const n = Number(raw);
|
|
32
54
|
return Number.isFinite(n) ? n : raw;
|
|
33
55
|
}
|
|
34
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Read a revision id off a canonical (or raw-ish) entity body.
|
|
59
|
+
* When `entityType` is known, only that type's revision attribute is read.
|
|
60
|
+
* When it is omitted, a node-style vid is kept and `revision_id` is used
|
|
61
|
+
* only if no vid is present, so existing callers stay unchanged.
|
|
62
|
+
*
|
|
63
|
+
* @param {?object} entity
|
|
64
|
+
* @param {?string} [entityType]
|
|
65
|
+
* @returns {?number|string}
|
|
66
|
+
*/
|
|
67
|
+
export function entityRevisionId(entity, entityType) {
|
|
68
|
+
if (!entity || typeof entity !== "object") return null;
|
|
69
|
+
const type = entityType || entity.entityType || null;
|
|
70
|
+
const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
|
|
71
|
+
const attrs = entity.attributes && typeof entity.attributes === "object" ? entity.attributes : {};
|
|
72
|
+
if (type === "taxonomy_term") return null;
|
|
73
|
+
if (type && REVISION_ATTRIBUTE[type]) {
|
|
74
|
+
return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, REVISION_ATTRIBUTE[type]));
|
|
75
|
+
}
|
|
76
|
+
const vid = readRevisionAttribute(entity, fields, attrs, "drupal_internal__vid");
|
|
77
|
+
if (vid !== undefined && vid !== null && vid !== "") return normalizeRevisionId(vid);
|
|
78
|
+
return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, "drupal_internal__revision_id"));
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
/**
|
|
36
82
|
* Attach distinct live vs working revision ids when both are known.
|
|
37
83
|
* Does not invent vids.
|
|
@@ -65,7 +111,8 @@ export async function attachWrittenRevisionPair({
|
|
|
65
111
|
backend, entityType, bundle, id, entity, liveVid,
|
|
66
112
|
}) {
|
|
67
113
|
if (liveVid === null || liveVid === undefined || !entity) return entity;
|
|
68
|
-
|
|
114
|
+
if (entity._revisions) return entity;
|
|
115
|
+
let workingVid = entityRevisionId(entity, entityType);
|
|
69
116
|
if (workingVid !== null && String(workingVid) !== String(liveVid)) {
|
|
70
117
|
return attachRevisionPair(entity, { live: liveVid, working: workingVid });
|
|
71
118
|
}
|
|
@@ -74,7 +121,7 @@ export async function attachWrittenRevisionPair({
|
|
|
74
121
|
const wc = await backend.getEntity({
|
|
75
122
|
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
76
123
|
});
|
|
77
|
-
workingVid = entityRevisionId(wc);
|
|
124
|
+
workingVid = entityRevisionId(wc, entityType);
|
|
78
125
|
} catch {
|
|
79
126
|
workingVid = null;
|
|
80
127
|
}
|
|
@@ -106,6 +153,9 @@ export async function readWrittenRevision({
|
|
|
106
153
|
// Sentinel returned the requested translation; a canonical read would replace
|
|
107
154
|
// it with live English, and the core working-copy alias may not address it.
|
|
108
155
|
if (langcode && patchResult) return patchResult;
|
|
156
|
+
if (patchResult?._revision?.source === "working-copy" || patchResult?._revision?.source === "latest-version") {
|
|
157
|
+
return patchResult;
|
|
158
|
+
}
|
|
109
159
|
if (!relationshipsSent) {
|
|
110
160
|
if (preferCanonical && typeof backend.getEntity === "function") {
|
|
111
161
|
const fresh = await backend.getEntity({
|
package/src/tools/bulk.js
CHANGED
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
import { getSiteConfig } from "../lib/config.js";
|
|
15
15
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
16
16
|
import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed, withUnpublishedDefault } from "../lib/security.js";
|
|
17
|
-
import { applySafeDraftDefault
|
|
17
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
18
|
+
import { readUpdateTarget } from "../lib/canonical-draft.js";
|
|
18
19
|
import {
|
|
19
20
|
resolveErrRelationships, embedParagraphRef,
|
|
20
21
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
21
22
|
} from "../lib/err-relationships.js";
|
|
22
23
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
24
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Normalize an unknown thrown value into a human-readable message.
|
|
@@ -52,11 +54,15 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
52
54
|
for (const [index, rawItem] of items.entries()) {
|
|
53
55
|
const item = rawItem || {};
|
|
54
56
|
try {
|
|
55
|
-
|
|
57
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
58
|
+
backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
|
|
59
|
+
defaultBodyFormat: false,
|
|
60
|
+
});
|
|
61
|
+
assertPublishAllowed(sec, formatted);
|
|
56
62
|
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
57
63
|
const entity = await backend.createEntity({
|
|
58
64
|
entityType, bundle,
|
|
59
|
-
attributes: withUnpublishedDefault(sec, entityType,
|
|
65
|
+
attributes: withUnpublishedDefault(sec, entityType, formatted),
|
|
60
66
|
relationships: resolvedRelationships,
|
|
61
67
|
});
|
|
62
68
|
created += 1;
|
|
@@ -103,17 +109,16 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
103
109
|
const item = rawItem || {};
|
|
104
110
|
try {
|
|
105
111
|
if (!item.id) throw new Error("Missing 'id' for update item");
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
112
|
+
const existing = await readUpdateTarget(backend, {
|
|
113
|
+
entityType, bundle, id: item.id, attributes: item.attributes ?? {},
|
|
114
|
+
});
|
|
115
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
116
|
+
backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
|
|
117
|
+
existingEntity: existing, defaultBodyFormat: false,
|
|
118
|
+
});
|
|
114
119
|
const attributes = await applySafeDraftDefault({
|
|
115
120
|
backend, entityType, bundle, id: item.id,
|
|
116
|
-
attributes:
|
|
121
|
+
attributes: formatted,
|
|
117
122
|
existingEntity: existing,
|
|
118
123
|
});
|
|
119
124
|
assertPublishAllowed(sec, attributes);
|
package/src/tools/entities.js
CHANGED
|
@@ -11,13 +11,15 @@
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
13
|
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
14
|
-
import { applySafeDraftDefault
|
|
14
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
15
15
|
import {
|
|
16
16
|
resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
|
|
17
17
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
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 { readUpdateTarget } from "../lib/canonical-draft.js";
|
|
22
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
21
23
|
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
22
24
|
import {
|
|
23
25
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
@@ -69,9 +71,12 @@ async function createEntity({ site: siteName, entityType, bundle, attributes: ca
|
|
|
69
71
|
const site = getSiteConfig(siteName);
|
|
70
72
|
const sec = resolveSecurityConfig(site);
|
|
71
73
|
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
72
|
-
assertPublishAllowed(sec, callerAttributes);
|
|
73
|
-
const attributes = withUnpublishedDefault(sec, entityType, callerAttributes);
|
|
74
74
|
const backend = await resolveBackend(site);
|
|
75
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
76
|
+
backend, site, entityType, bundle, attributes: { ...callerAttributes }, defaultBodyFormat: false,
|
|
77
|
+
});
|
|
78
|
+
assertPublishAllowed(sec, formatted);
|
|
79
|
+
const attributes = withUnpublishedDefault(sec, entityType, formatted);
|
|
75
80
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
76
81
|
if (dryRun) {
|
|
77
82
|
return {
|
|
@@ -111,16 +116,13 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
111
116
|
// One pre-write read serves both the #131 draft default and the #171
|
|
112
117
|
// unrequested-status-change flag. Skipped when the caller pinned the
|
|
113
118
|
// moderation state explicitly (same condition the draft default uses).
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
existing = null; // Unreadable target: server-side gates stay authoritative.
|
|
120
|
-
}
|
|
121
|
-
}
|
|
119
|
+
const existing = await readUpdateTarget(backend, { entityType, bundle, id, attributes });
|
|
120
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
121
|
+
backend, site, entityType, bundle, attributes: { ...attributes }, existingEntity: existing,
|
|
122
|
+
defaultBodyFormat: false,
|
|
123
|
+
});
|
|
122
124
|
const safeAttributes = await applySafeDraftDefault({
|
|
123
|
-
backend, entityType, bundle, id, attributes, existingEntity: existing,
|
|
125
|
+
backend, entityType, bundle, id, attributes: formatted, existingEntity: existing,
|
|
124
126
|
});
|
|
125
127
|
assertPublishAllowed(sec, safeAttributes);
|
|
126
128
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
@@ -297,7 +299,7 @@ export const definitions = [
|
|
|
297
299
|
},
|
|
298
300
|
{
|
|
299
301
|
name: "drupal_entity_update",
|
|
300
|
-
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.",
|
|
302
|
+
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. When the draft inventory advertises open_draft, a published moderated paragraphs_library_item or block_content opens and continues through /mcp-draft with If-Match live or live:working, no mcp_components, and a re-read of the published pins. A continuation compares the forward revision with the pre-write working pins. When open_draft is not advertised, a published moderated paragraphs_library_item or block_content with no forward revision is saved by that canonical draft PATCH, then the published revision, rel:working-copy, and paragraph pins (including order) are re-read. The result fails if the published revision changed or no forward revision appeared. An existing forward draft, a missing revision id, or a denied version read is refused before any write. An unmoderated item keeps the ordinary update. A completed 4xx is returned when that re-read matches the pre-write revision and pins; a lost response stays uncertain (#420).",
|
|
301
303
|
inputSchema: {
|
|
302
304
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
303
305
|
properties: {
|
|
@@ -308,7 +310,7 @@ export const definitions = [
|
|
|
308
310
|
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
309
311
|
attributes: { type: "object" },
|
|
310
312
|
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.
|
|
313
|
+
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. An existing node draft (or a langcode translation draft), and a paragraphs_library_item or block_content whose inventory advertises open_draft, are 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
314
|
returning: RETURNING_SCHEMA,
|
|
313
315
|
},
|
|
314
316
|
},
|
package/src/tools/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as references from "./references.js";
|
|
|
29
29
|
import * as bulk from "./bulk.js";
|
|
30
30
|
import * as translations from "./translations.js";
|
|
31
31
|
import * as paragraphs from "./paragraphs.js";
|
|
32
|
+
import * as nested from "./nested.js";
|
|
32
33
|
import * as structure from "./structure.js";
|
|
33
34
|
import * as redirects from "./redirects.js";
|
|
34
35
|
import * as search from "./search.js";
|
|
@@ -42,7 +43,7 @@ import * as codegen from "./codegen.js";
|
|
|
42
43
|
import { SITE_PARAM } from "../lib/site-target.js";
|
|
43
44
|
|
|
44
45
|
const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
45
|
-
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
46
|
+
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, nested, structure, redirects, search, reportsExtra,
|
|
46
47
|
reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
|
|
47
48
|
|
|
48
49
|
/**
|
package/src/tools/media.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
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";
|
|
22
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* List all media types (bundles of the media entity type).
|
|
@@ -110,8 +111,11 @@ async function createMedia({ site: siteName, type, name, status = false, fields
|
|
|
110
111
|
// Layer name/status after fields so they win, matching prior behaviour.
|
|
111
112
|
attributes.name = name;
|
|
112
113
|
attributes.status = status;
|
|
113
|
-
assertPublishAllowed(sec, attributes);
|
|
114
114
|
const backend = await resolveBackend(site);
|
|
115
|
+
await applyAllowedFormatsToAttributes({
|
|
116
|
+
backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
|
|
117
|
+
});
|
|
118
|
+
assertPublishAllowed(sec, attributes);
|
|
115
119
|
return backend.createEntity({
|
|
116
120
|
entityType: "media", bundle: type, attributes,
|
|
117
121
|
...(relationships ? { relationships } : {}),
|
|
@@ -134,8 +138,11 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
|
|
|
134
138
|
const { attributes, relationships } = splitReferenceFields(fields);
|
|
135
139
|
if (name !== undefined) attributes.name = name;
|
|
136
140
|
if (status !== undefined) attributes.status = status;
|
|
137
|
-
assertPublishAllowed(sec, attributes);
|
|
138
141
|
const backend = await resolveBackend(site);
|
|
142
|
+
await applyAllowedFormatsToAttributes({
|
|
143
|
+
backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
|
|
144
|
+
});
|
|
145
|
+
assertPublishAllowed(sec, attributes);
|
|
139
146
|
if (langcode) {
|
|
140
147
|
const targetLang = assertDraftLangcode(langcode);
|
|
141
148
|
const patchTarget = await prepareGuardedPatch(backend, {
|