drupal-mcp-connector 2.6.1 → 2.7.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/.claude/commands/drupal-bulk-create.md +2 -2
- package/.claude/commands/drupal-create-paragraph.md +2 -2
- package/.claude/commands/drupal-entity-update.md +3 -3
- package/.claude/commands/drupal-get-paragraph.md +2 -2
- package/.claude/commands/drupal-list-revisions.md +2 -2
- package/.claude/commands/drupal-list-sites.md +2 -2
- package/.claude/commands/drupal-update-node.md +4 -4
- package/.claude/commands/drupal-update-paragraph.md +2 -2
- package/CHANGELOG.md +80 -0
- package/README.md +2 -2
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +91 -21
- package/src/lib/backends/backend-interface.js +4 -1
- package/src/lib/backends/jsonapi.js +16 -6
- package/src/lib/canonical.js +11 -1
- package/src/lib/config.js +23 -0
- package/src/lib/dispatch.js +36 -10
- package/src/lib/err-relationships.js +286 -0
- package/src/lib/http-auth.js +541 -3
- package/src/lib/http-handler.js +62 -9
- package/src/lib/mcp-server.js +32 -6
- package/src/lib/patch-preflight.js +176 -0
- package/src/lib/principal.js +372 -0
- package/src/lib/verify.js +40 -4
- package/src/lib/write-revision.js +72 -0
- package/src/tools/bulk.js +31 -6
- package/src/tools/config.js +6 -0
- package/src/tools/entities.js +37 -7
- package/src/tools/nodes.js +34 -10
- package/src/tools/paragraphs.js +56 -42
- package/src/tools/revisions.js +42 -7
- package/src/tools/site.js +19 -5
package/src/lib/verify.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
21
|
import { CLIENT_VERSION } from "./config.js";
|
|
22
|
+
import { resolveInboundAuthConfig, resolveInboundAuthMode } from "./http-auth.js";
|
|
22
23
|
|
|
23
24
|
/** Check outcome vocabulary.
|
|
24
25
|
*
|
|
@@ -44,6 +45,7 @@ export const STATIC_CHECKS = [
|
|
|
44
45
|
"entitlement",
|
|
45
46
|
"target_resolution",
|
|
46
47
|
"tenant_neutrality",
|
|
48
|
+
"inbound_auth",
|
|
47
49
|
];
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -222,13 +224,14 @@ export function configDigest(config) {
|
|
|
222
224
|
* credentials, no side effects.
|
|
223
225
|
*
|
|
224
226
|
* @param {object} config Parsed connector configuration.
|
|
225
|
-
* @param {{source?: string, now?: () => Date}} [options]
|
|
227
|
+
* @param {{source?: string, now?: () => Date, env?: NodeJS.ProcessEnv}} [options]
|
|
226
228
|
* `source` names what was verified (a path, or a label) for the evidence;
|
|
227
|
-
* `now` is injectable so a run is reproducible in tests
|
|
229
|
+
* `now` is injectable so a run is reproducible in tests;
|
|
230
|
+
* `env` is the process environment under verification (defaults to `process.env`).
|
|
228
231
|
* @returns {object} Evidence document: tool, version, subject, checks,
|
|
229
232
|
* residuals and a summary. Never contains secret values.
|
|
230
233
|
*/
|
|
231
|
-
export function verifyStatic(config, { source = "config", now = () => new Date() } = {}) {
|
|
234
|
+
export function verifyStatic(config, { source = "config", now = () => new Date(), env = process.env } = {}) {
|
|
232
235
|
const sites = Object.entries(config?.sites ?? {});
|
|
233
236
|
const named = (name, message) => `${name}: ${message}`;
|
|
234
237
|
const nothingToCheck = sites.length === 0;
|
|
@@ -364,12 +367,44 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
|
|
|
364
367
|
const tenantNeutrality = check(
|
|
365
368
|
"tenant_neutrality",
|
|
366
369
|
"The configuration names no real tenant hosts or identifiers",
|
|
367
|
-
mentionedHosts(config?.sites ?? {})
|
|
370
|
+
mentionedHosts({ sites: config?.sites ?? {}, auth: config?.auth ?? {} })
|
|
368
371
|
.filter(({ host }) => !isNeutralHost(host))
|
|
369
372
|
.map(({ path, host }) => `${path}: "${host}" is not a documentation-reserved host; a shipped example must not name a real deployment.`),
|
|
370
373
|
{ skipped: nothingToCheck },
|
|
371
374
|
);
|
|
372
375
|
|
|
376
|
+
const inbound = resolveInboundAuthConfig(config, env);
|
|
377
|
+
const inboundFindings = [];
|
|
378
|
+
if (inbound.issuer || inbound.audience || inbound.resource) {
|
|
379
|
+
if (!inbound.issuer) inboundFindings.push("auth.issuer is missing.");
|
|
380
|
+
else if (!String(inbound.issuer).startsWith("https://")) {
|
|
381
|
+
inboundFindings.push("auth.issuer is not HTTPS.");
|
|
382
|
+
}
|
|
383
|
+
if (!inbound.audience) inboundFindings.push("auth.audience is missing.");
|
|
384
|
+
if (inbound.resource && !String(inbound.resource).startsWith("https://")) {
|
|
385
|
+
inboundFindings.push("auth.resource is not HTTPS.");
|
|
386
|
+
}
|
|
387
|
+
if (inbound.introspectionUrl && !String(inbound.introspectionUrl).startsWith("https://")) {
|
|
388
|
+
inboundFindings.push("auth.introspectionUrl is not HTTPS.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const transportName = env.MCP_TRANSPORT || "stdio";
|
|
392
|
+
if (transportName === "https" || transportName === "http") {
|
|
393
|
+
const decision = resolveInboundAuthMode({
|
|
394
|
+
bindHost: env.MCP_BIND_HOST || "0.0.0.0",
|
|
395
|
+
allowUnauth: env.MCP_ALLOW_UNAUTHENTICATED === "1",
|
|
396
|
+
sharedToken: env.MCP_AUTH_TOKEN || "",
|
|
397
|
+
resourceServer: inbound,
|
|
398
|
+
});
|
|
399
|
+
if (decision.mode === "fatal") inboundFindings.push(decision.reason);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const inboundAuth = check(
|
|
403
|
+
"inbound_auth",
|
|
404
|
+
"Network-facing HTTPS authenticates as an OAuth protected resource",
|
|
405
|
+
inboundFindings,
|
|
406
|
+
);
|
|
407
|
+
|
|
373
408
|
const checks = [
|
|
374
409
|
transport,
|
|
375
410
|
principalAuth,
|
|
@@ -379,6 +414,7 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
|
|
|
379
414
|
entitlement,
|
|
380
415
|
targetResolution,
|
|
381
416
|
tenantNeutrality,
|
|
417
|
+
inboundAuth,
|
|
382
418
|
];
|
|
383
419
|
|
|
384
420
|
const counts = checks.reduce(
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choose which entity body represents a write (#169).
|
|
3
|
+
*
|
|
4
|
+
* When relationships were sent, the canonical/default revision is the
|
|
5
|
+
* published node — after a draft ERR attach it still shows the *old* refs.
|
|
6
|
+
* Prefer `rel:working-copy`. If that alias is not addressable, return the
|
|
7
|
+
* PATCH body (or the canonical re-read) plus `_revision.relationshipsUnverified`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} args
|
|
12
|
+
* @param {object} args.backend
|
|
13
|
+
* @param {string} args.entityType
|
|
14
|
+
* @param {string} args.bundle
|
|
15
|
+
* @param {string} args.id
|
|
16
|
+
* @param {boolean} args.relationshipsSent
|
|
17
|
+
* @param {?object} [args.patchResult] Canonicalised PATCH response body.
|
|
18
|
+
* @param {boolean} [args.preferCanonical] When no relationships were sent,
|
|
19
|
+
* re-GET the canonical resource (nodes do this for the persisted alias).
|
|
20
|
+
* @returns {Promise<object>} Entity to return, with `_revision` when relevant.
|
|
21
|
+
*/
|
|
22
|
+
export async function readWrittenRevision({
|
|
23
|
+
backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
|
|
24
|
+
}) {
|
|
25
|
+
if (!relationshipsSent) {
|
|
26
|
+
if (preferCanonical && typeof backend.getEntity === "function") {
|
|
27
|
+
const fresh = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
|
|
28
|
+
return fresh ?? patchResult ?? { id };
|
|
29
|
+
}
|
|
30
|
+
return patchResult ?? { id };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Content-moderation working-copy aliases are a node (host) feature. Other
|
|
34
|
+
// entity types keep the PATCH body and an unverified marker.
|
|
35
|
+
let workingCopy = null;
|
|
36
|
+
if (entityType === "node" && typeof backend.getEntity === "function") {
|
|
37
|
+
try {
|
|
38
|
+
workingCopy = await backend.getEntity({
|
|
39
|
+
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
workingCopy = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (workingCopy) {
|
|
46
|
+
return {
|
|
47
|
+
...workingCopy,
|
|
48
|
+
_revision: {
|
|
49
|
+
source: "working-copy",
|
|
50
|
+
note:
|
|
51
|
+
"Returned from rel:working-copy — the revision that was written, not the " +
|
|
52
|
+
"published default. Canonical re-reads hide a draft ERR attach (#169).",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let fallback = patchResult;
|
|
58
|
+
if (!fallback && preferCanonical && typeof backend.getEntity === "function") {
|
|
59
|
+
fallback = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
...(fallback ?? { id }),
|
|
63
|
+
_revision: {
|
|
64
|
+
source: patchResult ? "patch" : "canonical",
|
|
65
|
+
relationshipsUnverified: true,
|
|
66
|
+
note:
|
|
67
|
+
"Relationships were sent on this write. The body below is not the written " +
|
|
68
|
+
"revision (no addressable working copy). It is not proof an ERR field landed. " +
|
|
69
|
+
"Inspect rel:working-copy with drupal_get_revision, or treat the field as unverified (#169).",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/tools/bulk.js
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
import { getSiteConfig } from "../lib/config.js";
|
|
15
15
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
16
16
|
import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
|
|
17
|
-
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
17
|
+
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
18
|
+
import {
|
|
19
|
+
resolveErrRelationships, embedParagraphRef,
|
|
20
|
+
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
21
|
+
} from "../lib/err-relationships.js";
|
|
22
|
+
import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
25
|
* Normalize an unknown thrown value into a human-readable message.
|
|
@@ -48,13 +53,20 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
48
53
|
const item = rawItem || {};
|
|
49
54
|
try {
|
|
50
55
|
assertPublishAllowed(sec, item.attributes ?? {});
|
|
56
|
+
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
51
57
|
const entity = await backend.createEntity({
|
|
52
58
|
entityType, bundle,
|
|
53
59
|
attributes: item.attributes ?? {},
|
|
54
|
-
relationships:
|
|
60
|
+
relationships: resolvedRelationships,
|
|
55
61
|
});
|
|
56
62
|
created += 1;
|
|
57
|
-
|
|
63
|
+
const row = { index, success: true, id: entity?.id };
|
|
64
|
+
if (entityType === "paragraph" && entity?.id) {
|
|
65
|
+
const revisionId = await resolveParagraphRevisionId(backend, entity, bundle);
|
|
66
|
+
if (revisionId === null) throw missingParagraphRevisionError(entity.id);
|
|
67
|
+
row.relationshipData = embedParagraphRef(entity.bundle || bundle, entity.id, revisionId);
|
|
68
|
+
}
|
|
69
|
+
results.push(row);
|
|
58
70
|
} catch (err) {
|
|
59
71
|
failed += 1;
|
|
60
72
|
results.push({ index, success: false, error: errorMessage(err) });
|
|
@@ -91,15 +103,28 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
91
103
|
const item = rawItem || {};
|
|
92
104
|
try {
|
|
93
105
|
if (!item.id) throw new Error("Missing 'id' for update item");
|
|
106
|
+
let existing = null;
|
|
107
|
+
if (!hasExplicitModerationState(item.attributes ?? {})) {
|
|
108
|
+
try {
|
|
109
|
+
existing = (await backend.getEntity({ entityType, bundle, id: item.id })) ?? null;
|
|
110
|
+
} catch {
|
|
111
|
+
existing = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
94
114
|
const attributes = await applySafeDraftDefault({
|
|
95
115
|
backend, entityType, bundle, id: item.id,
|
|
96
116
|
attributes: item.attributes ?? {},
|
|
117
|
+
existingEntity: existing,
|
|
97
118
|
});
|
|
98
119
|
assertPublishAllowed(sec, attributes);
|
|
99
|
-
const
|
|
120
|
+
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
121
|
+
await preflightPatchWritable({
|
|
122
|
+
backend, entityType, bundle, id: item.id, existing, attributes,
|
|
123
|
+
});
|
|
124
|
+
const entity = await updateEntityGuarded(backend, {
|
|
100
125
|
entityType, bundle, id: item.id,
|
|
101
126
|
attributes,
|
|
102
|
-
relationships:
|
|
127
|
+
relationships: resolvedRelationships,
|
|
103
128
|
});
|
|
104
129
|
updated += 1;
|
|
105
130
|
results.push({ index, success: true, id: entity?.id ?? item.id });
|
|
@@ -123,7 +148,7 @@ const itemAttributesSchema = {
|
|
|
123
148
|
export const definitions = [
|
|
124
149
|
{
|
|
125
150
|
name: "drupal_bulk_create",
|
|
126
|
-
description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft.",
|
|
151
|
+
description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Paragraph items also return relationshipData with meta.target_revision_id for a later host attach. Writes default to unpublished/draft.",
|
|
127
152
|
inputSchema: {
|
|
128
153
|
type: "object", required: ["entityType", "bundle", "items"],
|
|
129
154
|
properties: {
|
package/src/tools/config.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { getSiteConfig } from "../lib/config.js";
|
|
15
|
+
import { describeTarget, getRequestIdentity } from "../lib/principal.js";
|
|
15
16
|
import {
|
|
16
17
|
resolveSecurityConfig,
|
|
17
18
|
getSecuritySummary,
|
|
@@ -117,8 +118,13 @@ async function whoami({ site: siteName }) {
|
|
|
117
118
|
// When no OAuth scopes are configured, hasScope() is a no-op (preset-only).
|
|
118
119
|
const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
|
|
119
120
|
const canConfig = hasScope(site, "mcp_config");
|
|
121
|
+
const identity = getRequestIdentity();
|
|
120
122
|
return {
|
|
121
123
|
site: site._name,
|
|
124
|
+
target: describeTarget(site, siteName ? "hint" : "default"),
|
|
125
|
+
principal: identity
|
|
126
|
+
? { sub: identity.sub, clientId: identity.clientId, scopes: [...(identity.scopes ?? [])] }
|
|
127
|
+
: null,
|
|
122
128
|
tier: inferTier(site, sec),
|
|
123
129
|
preset: summary.preset,
|
|
124
130
|
scopes: site.oauth?.scopes ?? [],
|
package/src/tools/entities.js
CHANGED
|
@@ -12,6 +12,12 @@ 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
14
|
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
15
|
+
import {
|
|
16
|
+
resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
|
|
17
|
+
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
18
|
+
} from "../lib/err-relationships.js";
|
|
19
|
+
import { readWrittenRevision } from "../lib/write-revision.js";
|
|
20
|
+
import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
15
21
|
import {
|
|
16
22
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
17
23
|
redactCanonicalEntity, getSecuritySummary,
|
|
@@ -63,9 +69,16 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
63
69
|
const sec = resolveSecurityConfig(site);
|
|
64
70
|
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
65
71
|
assertPublishAllowed(sec, attributes);
|
|
66
|
-
if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships };
|
|
67
72
|
const backend = await resolveBackend(site);
|
|
68
|
-
|
|
73
|
+
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
74
|
+
if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships: resolvedRelationships };
|
|
75
|
+
const created = await backend.createEntity({ entityType, bundle, attributes, relationships: resolvedRelationships });
|
|
76
|
+
if (entityType === "paragraph") {
|
|
77
|
+
const revisionId = await resolveParagraphRevisionId(backend, created, bundle);
|
|
78
|
+
if (revisionId === null) throw missingParagraphRevisionError(created.id);
|
|
79
|
+
created.relationshipData = embedParagraphRef(created.bundle || bundle, created.id, revisionId);
|
|
80
|
+
}
|
|
81
|
+
return shapeWriteResponse(created, returning);
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
/**
|
|
@@ -103,9 +116,26 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
103
116
|
backend, entityType, bundle, id, attributes, existingEntity: existing,
|
|
104
117
|
});
|
|
105
118
|
assertPublishAllowed(sec, safeAttributes);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
120
|
+
await preflightPatchWritable({
|
|
121
|
+
backend, entityType, bundle, id, existing, attributes: safeAttributes,
|
|
122
|
+
});
|
|
123
|
+
if (dryRun) {
|
|
124
|
+
return {
|
|
125
|
+
dryRun: true, operation: "update", entityType, bundle, id,
|
|
126
|
+
attributes: safeAttributes, relationships: resolvedRelationships,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const result = await updateEntityGuarded(backend, {
|
|
130
|
+
entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
131
|
+
});
|
|
132
|
+
const written = await readWrittenRevision({
|
|
133
|
+
backend, entityType, bundle, id,
|
|
134
|
+
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
135
|
+
patchResult: result,
|
|
136
|
+
preferCanonical: false,
|
|
137
|
+
});
|
|
138
|
+
return shapeWriteResponse(flagUnrequestedStatusChange(written, existing, safeAttributes), returning);
|
|
109
139
|
}
|
|
110
140
|
|
|
111
141
|
/**
|
|
@@ -246,7 +276,7 @@ export const definitions = [
|
|
|
246
276
|
},
|
|
247
277
|
{
|
|
248
278
|
name: "drupal_entity_update",
|
|
249
|
-
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).",
|
|
279
|
+
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 an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
|
|
250
280
|
inputSchema: {
|
|
251
281
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
252
282
|
properties: {
|
|
@@ -256,7 +286,7 @@ export const definitions = [
|
|
|
256
286
|
id: { type: "string" },
|
|
257
287
|
attributes: { type: "object" },
|
|
258
288
|
relationships: { type: "object" },
|
|
259
|
-
dryRun: { type: "boolean", default: false, description: "Validate and return a preview
|
|
289
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
|
|
260
290
|
returning: RETURNING_SCHEMA,
|
|
261
291
|
},
|
|
262
292
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
} from "../lib/security.js";
|
|
16
16
|
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
17
17
|
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
18
|
+
import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
|
|
19
|
+
import { readWrittenRevision } from "../lib/write-revision.js";
|
|
20
|
+
import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
18
21
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
19
22
|
|
|
20
23
|
/** Fallback language for an alias when the node exposes none. */
|
|
@@ -269,14 +272,15 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
269
272
|
const bodyAttr = buildBodyAttribute(body, summary, format, site);
|
|
270
273
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
271
274
|
assertPublishAllowed(sec, attributes);
|
|
272
|
-
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
|
|
273
275
|
const backend = await resolveBackend(site);
|
|
276
|
+
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
277
|
+
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships };
|
|
274
278
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
275
279
|
// `path` is omitted so pathauto generates the alias (DEV-116).
|
|
276
280
|
const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
|
|
277
281
|
if (pathAttr === undefined) delete attributes.path;
|
|
278
282
|
else attributes.path = pathAttr;
|
|
279
|
-
const created = await backend.createEntity({ entityType: "node", bundle: type, attributes, relationships });
|
|
283
|
+
const created = await backend.createEntity({ entityType: "node", bundle: type, attributes, relationships: resolvedRelationships });
|
|
280
284
|
// Honest response: re-read so the persisted alias (explicit or pathauto-generated)
|
|
281
285
|
// is reflected rather than the pre-alias write response.
|
|
282
286
|
const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
|
|
@@ -334,7 +338,20 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
334
338
|
backend, entityType: "node", bundle: type, id, attributes, existingEntity: existing,
|
|
335
339
|
});
|
|
336
340
|
assertPublishAllowed(sec, attributes);
|
|
337
|
-
|
|
341
|
+
// #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
|
|
342
|
+
// list would persist empty — fail the whole write instead.
|
|
343
|
+
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
344
|
+
// #201: the core working-copy guard runs before deserialize. A no-op probe
|
|
345
|
+
// against the same canonical URL is a true preflight, including on dryRun.
|
|
346
|
+
await preflightPatchWritable({
|
|
347
|
+
backend, entityType: "node", bundle: type, id, existing, attributes,
|
|
348
|
+
});
|
|
349
|
+
if (dryRun) {
|
|
350
|
+
return {
|
|
351
|
+
dryRun: true, operation: "update", entityType: "node", bundle: type, id,
|
|
352
|
+
attributes, relationships: resolvedRelationships,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
338
355
|
// Alias handling (DEV-116): an explicit `path.alias` is set in place by
|
|
339
356
|
// round-tripping the existing alias's pid (no duplicate); a path-less update
|
|
340
357
|
// re-pins the current alias *with its pid* so the save can't revert/duplicate
|
|
@@ -342,11 +359,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
342
359
|
const { pathAttr, redirect } = await resolvePathWrite({ backend, type, id, providedPath: attributes.path, isCreate: false });
|
|
343
360
|
if (pathAttr === undefined) delete attributes.path;
|
|
344
361
|
else attributes.path = pathAttr;
|
|
345
|
-
|
|
362
|
+
const patched = await updateEntityGuarded(backend, {
|
|
363
|
+
entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
|
|
364
|
+
});
|
|
346
365
|
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
const fresh = await
|
|
366
|
+
// #169: when relationships were sent, the canonical re-read is the published
|
|
367
|
+
// revision and is not proof an ERR field landed. Prefer rel:working-copy.
|
|
368
|
+
const fresh = await readWrittenRevision({
|
|
369
|
+
backend, entityType: "node", bundle: type, id,
|
|
370
|
+
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
371
|
+
patchResult: patched,
|
|
372
|
+
preferCanonical: true,
|
|
373
|
+
});
|
|
350
374
|
// #171: an unrequested published-state flip in the persisted node is
|
|
351
375
|
// reported via _statusChanged rather than returned as a clean success.
|
|
352
376
|
const flagged = flagUnrequestedStatusChange(fresh, existing, attributes);
|
|
@@ -441,7 +465,7 @@ export const definitions = [
|
|
|
441
465
|
},
|
|
442
466
|
{
|
|
443
467
|
name: "drupal_update_node",
|
|
444
|
-
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. Entity-reference fields go in `relationships`, not `fields`.",
|
|
468
|
+
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. 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 an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. 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.",
|
|
445
469
|
inputSchema: {
|
|
446
470
|
type: "object", required: ["type", "id"],
|
|
447
471
|
properties: {
|
|
@@ -455,8 +479,8 @@ export const definitions = [
|
|
|
455
479
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
456
480
|
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
|
|
457
481
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
|
|
458
|
-
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 }, …] }." },
|
|
459
|
-
dryRun: { type: "boolean", default: false, description: "Validate and return a preview
|
|
482
|
+
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." },
|
|
483
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
|
|
460
484
|
returning: RETURNING_SCHEMA,
|
|
461
485
|
},
|
|
462
486
|
},
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -9,17 +9,24 @@
|
|
|
9
9
|
* focused way to mint a paragraph and fetch it back, plus the relationship data
|
|
10
10
|
* needed to embed it into a host field.
|
|
11
11
|
*
|
|
12
|
-
* Embedding model — IMPORTANT:
|
|
13
|
-
* - Over JSON:API
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
12
|
+
* Embedding model — IMPORTANT (#192):
|
|
13
|
+
* - Over JSON:API a host references a paragraph from its ERR field by a
|
|
14
|
+
* resource identifier `{ type: "paragraph--<bundle>", id, meta: {
|
|
15
|
+
* target_revision_id } }`. Drupal's ERR item is empty unless both
|
|
16
|
+
* `target_id` and `target_revision_id` are set; JSON:API only receives the
|
|
17
|
+
* revision id from `meta.target_revision_id`. `{ type, id }` alone persists
|
|
18
|
+
* an empty field — it is not a no-op, and it is worse than omitting the
|
|
19
|
+
* field (which would inherit the previous revision's refs).
|
|
20
|
+
* - `relationshipData` from these tools includes that meta key. Host writes
|
|
21
|
+
* (`drupal_entity_update` / `drupal_update_node` / bulk update) also resolve
|
|
22
|
+
* a missing vid before PATCH and fail the whole write if they cannot.
|
|
19
23
|
* - The classic entity-API pair `{ target_id, target_revision_id }` (integer
|
|
20
|
-
* ids) is the REST/Form-API shape, not the JSON:API shape.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
24
|
+
* ids) is the REST/Form-API shape, not the JSON:API shape.
|
|
25
|
+
* - Creating paragraphs and then attaching them is two calls. A content-tier
|
|
26
|
+
* agent cannot delete orphans if the host PATCH is then rejected (#201).
|
|
27
|
+
* Probe the host first: `drupal_list_revisions` (`possiblyPatchBlocked`)
|
|
28
|
+
* and `dryRun` on the host update. Preflight inside `update_node` does not
|
|
29
|
+
* un-orphan work that already happened.
|
|
23
30
|
*
|
|
24
31
|
* Both tools are governed: writes assert create permission for the `paragraph`
|
|
25
32
|
* entity type + bundle, reads assert read permission and are redacted per the
|
|
@@ -32,33 +39,35 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
32
39
|
import {
|
|
33
40
|
resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
|
|
34
41
|
} from "../lib/security.js";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* @param {string} bundle Paragraph type machine name.
|
|
39
|
-
* @returns {string} e.g. "paragraph--text".
|
|
40
|
-
*/
|
|
41
|
-
function resourceType(bundle) {
|
|
42
|
-
return `paragraph--${bundle}`;
|
|
43
|
-
}
|
|
42
|
+
import {
|
|
43
|
+
embedParagraphRef, paragraphRevisionId, resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
44
|
+
} from "../lib/err-relationships.js";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Build the resource-identifier ref used to embed a paragraph in a host ERR /
|
|
47
|
-
* paragraph reference field over JSON:API.
|
|
48
|
+
* paragraph reference field over JSON:API. Includes `meta.target_revision_id`
|
|
49
|
+
* when a vid is known (#192).
|
|
48
50
|
* @param {string} bundle Paragraph type machine name.
|
|
49
51
|
* @param {string} id Paragraph UUID.
|
|
50
|
-
* @
|
|
52
|
+
* @param {number|string|null|undefined} [revisionId] Current revision id.
|
|
53
|
+
* @returns {{type: string, id: string, meta?: {target_revision_id: number}}}
|
|
51
54
|
*/
|
|
52
|
-
function embedRef(bundle, id) {
|
|
53
|
-
return
|
|
55
|
+
export function embedRef(bundle, id, revisionId) {
|
|
56
|
+
return embedParagraphRef(bundle, id, revisionId);
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
const EMBED_NOTE =
|
|
57
60
|
"Paragraphs are not standalone content: embed this paragraph in a host entity's " +
|
|
58
|
-
"Entity Reference Revisions (paragraph) field. Over JSON:API
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
61
|
+
"Entity Reference Revisions (paragraph) field. Over JSON:API the resource identifier " +
|
|
62
|
+
"MUST include meta.target_revision_id — Drupal's ERR item is empty unless both " +
|
|
63
|
+
"target_id and target_revision_id are set, and JSON:API only receives the revision " +
|
|
64
|
+
"id from that meta key. relationshipData from this tool includes it. " +
|
|
65
|
+
"drupal_entity_update / drupal_update_node also resolve a missing vid before PATCH " +
|
|
66
|
+
"and fail the write if they cannot. An empty array is an explicit clear. " +
|
|
67
|
+
"Before creating paragraphs to attach to a published moderated node, call " +
|
|
68
|
+
"drupal_list_revisions (inspect possiblyPatchBlocked) and dryRun the host update — " +
|
|
69
|
+
"the host write is rejected after dependents exist, and content-tier cannot delete " +
|
|
70
|
+
"the orphans (#201).";
|
|
62
71
|
|
|
63
72
|
/**
|
|
64
73
|
* Create a paragraph entity of the given type and return a ref suitable for
|
|
@@ -68,10 +77,11 @@ const EMBED_NOTE =
|
|
|
68
77
|
* `attributes` are paragraph field values keyed by Drupal machine name
|
|
69
78
|
* (e.g. { field_body: { value, format } }). Use drupal_get_entity_schema for
|
|
70
79
|
* entityType "paragraph" + the bundle to discover available fields.
|
|
71
|
-
* @returns {Promise<{paragraph: object, ref:
|
|
72
|
-
*
|
|
73
|
-
*
|
|
80
|
+
* @returns {Promise<{paragraph: object, ref: object, relationshipData: object, note: string}>}
|
|
81
|
+
* The created paragraph descriptor plus the embedding ref/relationship data
|
|
82
|
+
* (including `meta.target_revision_id`).
|
|
74
83
|
* @throws {SecurityError} If creating paragraphs of this bundle is not permitted.
|
|
84
|
+
* @throws {Error} If the created paragraph has no readable revision id.
|
|
75
85
|
*/
|
|
76
86
|
async function createParagraph({ site: siteName, paragraphType, attributes = {} }) {
|
|
77
87
|
const site = getSiteConfig(siteName);
|
|
@@ -80,7 +90,9 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
|
|
|
80
90
|
const backend = await resolveBackend(site);
|
|
81
91
|
const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes });
|
|
82
92
|
const bundle = paragraph.bundle || paragraphType;
|
|
83
|
-
const
|
|
93
|
+
const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
|
|
94
|
+
if (revisionId === null) throw missingParagraphRevisionError(paragraph.id);
|
|
95
|
+
const ref = embedRef(bundle, paragraph.id, revisionId);
|
|
84
96
|
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
85
97
|
}
|
|
86
98
|
|
|
@@ -94,10 +106,9 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
|
|
|
94
106
|
* @param {object} args - { site?, paragraphType, id, attributes? }.
|
|
95
107
|
* `attributes` are the paragraph field values to change, keyed by Drupal
|
|
96
108
|
* machine name (e.g. { field_body: { value, format } }).
|
|
97
|
-
* @returns {Promise<{paragraph: object, ref:
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @throws {Error} If id is missing.
|
|
109
|
+
* @returns {Promise<{paragraph: object, ref: object, relationshipData: object, note: string}>}
|
|
110
|
+
* The updated paragraph plus the embedding ref (with current revision id).
|
|
111
|
+
* @throws {Error} If id is missing or the revision id cannot be read.
|
|
101
112
|
* @throws {SecurityError} If updating paragraphs of this bundle is not permitted.
|
|
102
113
|
*/
|
|
103
114
|
async function updateParagraph({ site: siteName, paragraphType, id, attributes = {} }) {
|
|
@@ -108,16 +119,18 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
|
|
|
108
119
|
const backend = await resolveBackend(site);
|
|
109
120
|
const paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
|
|
110
121
|
const bundle = paragraph.bundle || paragraphType;
|
|
111
|
-
const
|
|
122
|
+
const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
|
|
123
|
+
if (revisionId === null) throw missingParagraphRevisionError(id, "Updated");
|
|
124
|
+
const ref = embedRef(bundle, paragraph.id, revisionId);
|
|
112
125
|
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
/**
|
|
116
129
|
* Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
|
|
117
|
-
* annotate it with the embedding ref.
|
|
130
|
+
* annotate it with the embedding ref (including `meta.target_revision_id`).
|
|
118
131
|
*
|
|
119
132
|
* @param {object} args - { site?, paragraphType, id }.
|
|
120
|
-
* @returns {Promise<(object & {ref:
|
|
133
|
+
* @returns {Promise<(object & {ref: object})|null>}
|
|
121
134
|
* The redacted paragraph with an embedding `ref`, or null if not found.
|
|
122
135
|
* @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
|
|
123
136
|
*/
|
|
@@ -129,7 +142,8 @@ async function getParagraph({ site: siteName, paragraphType, id }) {
|
|
|
129
142
|
const entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
|
|
130
143
|
if (!entity) return null;
|
|
131
144
|
const redacted = redactCanonicalEntity(entity, sec, "paragraph");
|
|
132
|
-
|
|
145
|
+
const revisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted);
|
|
146
|
+
return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, revisionId) };
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
// ---------------------------------------------------------------------------
|
|
@@ -140,7 +154,7 @@ export const definitions = [
|
|
|
140
154
|
{
|
|
141
155
|
name: "drupal_create_paragraph",
|
|
142
156
|
description:
|
|
143
|
-
"Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id:
|
|
157
|
+
"Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Drupal ERR items are empty without that meta key — do not send {type, id} alone. Before creating paragraphs to attach to a published moderated node, call drupal_list_revisions (possiblyPatchBlocked) and dryRun the host update so a doomed PATCH does not orphan them. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.",
|
|
144
158
|
inputSchema: {
|
|
145
159
|
type: "object", required: ["paragraphType"],
|
|
146
160
|
properties: {
|
|
@@ -153,7 +167,7 @@ export const definitions = [
|
|
|
153
167
|
{
|
|
154
168
|
name: "drupal_update_paragraph",
|
|
155
169
|
description:
|
|
156
|
-
"Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to
|
|
170
|
+
"Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
|
|
157
171
|
inputSchema: {
|
|
158
172
|
type: "object", required: ["paragraphType", "id"],
|
|
159
173
|
properties: {
|
|
@@ -167,7 +181,7 @@ export const definitions = [
|
|
|
167
181
|
{
|
|
168
182
|
name: "drupal_get_paragraph",
|
|
169
183
|
description:
|
|
170
|
-
"Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field.
|
|
184
|
+
"Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
|
|
171
185
|
inputSchema: {
|
|
172
186
|
type: "object", required: ["paragraphType", "id"],
|
|
173
187
|
properties: {
|