drupal-mcp-connector 2.23.3 → 2.24.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-create-node.md +1 -1
- package/.agents/commands/drupal-update-node.md +2 -2
- package/CHANGELOG.md +40 -0
- package/README.md +7 -6
- package/config/config.example.json +1 -1
- package/package.json +1 -1
- package/src/lib/backends/backend-interface.js +5 -4
- package/src/lib/backends/jsonapi.js +20 -13
- package/src/lib/field-definition.js +83 -20
- package/src/lib/security.js +33 -2
- package/src/lib/validate.js +0 -44
- package/src/lib/workflow-prompts.js +1 -25
- package/src/tools/bulk.js +2 -2
- package/src/tools/entities.js +4 -3
- package/src/tools/nodes.js +14 -10
|
@@ -17,7 +17,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
17
17
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
18
18
|
- `body` (string): Body field HTML
|
|
19
19
|
- `summary` (string): Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`.
|
|
20
|
-
- `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When
|
|
20
|
+
- `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'.
|
|
21
21
|
- `status` (boolean (true/false)): Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically.
|
|
22
22
|
- `moderationState` (string): Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status.
|
|
23
23
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by Drupal machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`.
|
|
@@ -18,13 +18,13 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
18
18
|
- `title` (string)
|
|
19
19
|
- `body` (string)
|
|
20
20
|
- `summary` (string): Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`.
|
|
21
|
-
- `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When
|
|
21
|
+
- `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before the preview and the write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, an update reuses the format already stored on the field; if the node has none, the site config's `defaultTextFormat`, then 'full_html'. dryRun previews that same format, including on the published-node core PATCH guard.
|
|
22
22
|
- `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
|
|
23
23
|
- `moderationState` (string): 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'.
|
|
24
24
|
- `langcode` (string): Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode.
|
|
25
25
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
|
|
26
26
|
- `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused.
|
|
27
|
-
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access
|
|
27
|
+
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
|
|
28
28
|
- `returning` (one of: full, minimal): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
29
29
|
|
|
30
30
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.24.1] - 2026-09-29
|
|
11
|
+
|
|
12
|
+
### Removed
|
|
13
|
+
- **Unused validate helpers and stale workflow-prompt exports.** `clampLimit` /
|
|
14
|
+
`MAX_PAGE_LIMIT` and `validateFieldName` / `FIELD_NAME_RE` had no callers.
|
|
15
|
+
`workflowIndex`, `WORKFLOW_LIMITS`, and the unused `toolNameToPromptName`
|
|
16
|
+
re-export are gone. Filter/sort field names stay unvalidated so JSON:API
|
|
17
|
+
relationship paths keep working; `URLSearchParams` remains the T3 control.
|
|
18
|
+
|
|
19
|
+
## [2.24.0] - 2026-09-29
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **Paragraphs Library items on the content tiers.** `paragraphs_library_item`
|
|
23
|
+
joins the structural content set, so the `content-editor`, `config-editor`
|
|
24
|
+
and `write-plane` presets can create and edit reusable library items through
|
|
25
|
+
`drupal_entity_*`. Deletes stay blocked. A write carrying
|
|
26
|
+
`moderation_state: published` or `status: true` is refused when
|
|
27
|
+
`allowPublish` is false, as for every other type; drafts pass. On a site
|
|
28
|
+
that moderates library items, Drupal's workflow is the second gate.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **A library item created by a tier that cannot publish stays unpublished.**
|
|
32
|
+
Drupal publishes Paragraphs Library items by default. On a site that does
|
|
33
|
+
not moderate them, `drupal_entity_create` or `drupal_bulk_create` without
|
|
34
|
+
`status` put the item live on every page that places it, although
|
|
35
|
+
`allowPublish` was false. When publishing is not allowed and the caller sets
|
|
36
|
+
neither `status` nor `moderation_state`, the connector now sends
|
|
37
|
+
`status: false`. Other entity types are unchanged.
|
|
38
|
+
- **Node update preflight no longer accepts a body format the save rejects (#327).**
|
|
39
|
+
Node body is a base field. Its `allowed_formats` live on
|
|
40
|
+
`base_field_override`, which field lookup did not read, so an omitted format
|
|
41
|
+
fell back to `defaultTextFormat` / `full_html`. On a published moderated node
|
|
42
|
+
with no working copy, `dryRun` only probes core's working-copy guard and does
|
|
43
|
+
not send fields, so the preview succeeded and the save returned 422
|
|
44
|
+
`body.0.format is not a valid choice`. Lookup now reads
|
|
45
|
+
`base_field_override` (JSON:API, then Drush
|
|
46
|
+
`core.base_field_override.*`). When the allowed list is still unknown, an
|
|
47
|
+
update reuses the format already stored on the field. The preview and the
|
|
48
|
+
save resolve that same format, including on the core PATCH-guard path.
|
|
49
|
+
|
|
10
50
|
## [2.23.3] - 2026-09-28
|
|
11
51
|
|
|
12
52
|
### Fixed
|
package/README.md
CHANGED
|
@@ -55,12 +55,13 @@ Each site declares which backend(s) it exposes via the `api` key:
|
|
|
55
55
|
- **`defaultTextFormat` sets the body text format** used by the `body` convenience
|
|
56
56
|
parameter on node writes, e.g. `{ "baseUrl": "…", "defaultTextFormat": "basic_html" }`.
|
|
57
57
|
Individual calls can override it with `format`. Field API `allowed_formats` wins
|
|
58
|
-
when it can be resolved
|
|
59
|
-
|
|
60
|
-
back to `full_html`, which many governed
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
when it can be resolved (from `field_config` or, for body, `base_field_override`):
|
|
59
|
+
a single allowed format is the default, and a format outside the list is refused.
|
|
60
|
+
Without a resolved list, a create falls back to `full_html`, which many governed
|
|
61
|
+
sites deliberately do not define — and which is the most permissive core format,
|
|
62
|
+
so setting this per site is the safer posture. An update reuses the format already
|
|
63
|
+
stored on the field instead of that fallback. A text format is Drupal's
|
|
64
|
+
HTML-filtering boundary, so it is worth choosing deliberately rather than inheriting.
|
|
64
65
|
|
|
65
66
|
See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction and **[docs/graphql-local-setup.md](docs/graphql-local-setup.md)** for the GraphQL specifics.
|
|
66
67
|
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
|
|
147
147
|
"_security_presets": {
|
|
148
148
|
"development": "All operations allowed, incl. config read/write. Loopback / break-glass only.",
|
|
149
|
-
"content-editor": "Create/edit the full content set (node, media, taxonomy_term, paragraph, block_content, menu_link_content, redirect, path_alias, file). No deletes. Config read-only. Denies user + secrets/governance types.",
|
|
149
|
+
"content-editor": "Create/edit the full content set (node, media, taxonomy_term, paragraph, paragraphs_library_item, block_content, menu_link_content, redirect, path_alias, file). No deletes. Config read-only. Denies user + secrets/governance types.",
|
|
150
150
|
"config-editor": "content-editor + site-building config entities (read/introspection) + governed config read/write (Developer tier). Model changes go via the config bridge, not JSON:API.",
|
|
151
151
|
"auditor": "Read-only. All entity types. User PII fields redacted. Config read-only.",
|
|
152
152
|
"production-strict": "Read-only. No user entities. Broad PII redaction. No config access.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.24.1",
|
|
4
4
|
"description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -151,10 +151,11 @@ export class Backend {
|
|
|
151
151
|
* Resolve Field API metadata for one field (internal introspection).
|
|
152
152
|
*
|
|
153
153
|
* `getEntitySchema` is sampling-only and does NOT expose `allowed_formats`.
|
|
154
|
-
* The JSON:API adapter reads `field_config
|
|
155
|
-
* agent entity tools (`field_config` is on
|
|
156
|
-
* null when the definition cannot be resolved.
|
|
157
|
-
* `allowed_formats` from `defaultTextFormat` or
|
|
154
|
+
* The JSON:API adapter reads `field_config`, then `base_field_override`,
|
|
155
|
+
* via an internal fetch — not via agent entity tools (`field_config` is on
|
|
156
|
+
* the agent deny list). Returns null when the definition cannot be resolved.
|
|
157
|
+
* Callers must not invent `allowed_formats` from `defaultTextFormat` or
|
|
158
|
+
* `full_html`.
|
|
158
159
|
*
|
|
159
160
|
* Default: unknown. GraphQL is read-only for writes; write sites use JSON:API.
|
|
160
161
|
*
|
|
@@ -729,15 +729,17 @@ export class JsonApiBackend extends Backend {
|
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
/**
|
|
732
|
-
* Read Field API metadata from JSON:API
|
|
732
|
+
* Read Field API metadata from JSON:API (chain step 1).
|
|
733
733
|
*
|
|
734
|
+
* Configurable fields live on `field_config`. Node body is a base field:
|
|
735
|
+
* its `allowed_formats` live on `base_field_override`, not `field_config`.
|
|
734
736
|
* This is an internal introspection call, not an agent entity-tool read:
|
|
735
737
|
* `field_config` is on the connector deny list. Do not invent
|
|
736
738
|
* `allowed_formats` from `this.site.defaultTextFormat` or `full_html`.
|
|
737
|
-
* Empty / unreadable
|
|
738
|
-
*
|
|
739
|
-
* that also fails, keep the historical default chain only while the
|
|
740
|
-
* is unknown.
|
|
739
|
+
* Empty / unreadable rows return null so the caller can try Drush
|
|
740
|
+
* `config:get` (`field.field.*`, then `core.base_field_override.*`) and,
|
|
741
|
+
* if that also fails, keep the historical default chain only while the
|
|
742
|
+
* list is unknown. Updates reuse a stored format in that case (#327).
|
|
741
743
|
*
|
|
742
744
|
* @param {{entityType: string, bundle: string, fieldName: string}} ref
|
|
743
745
|
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
@@ -790,14 +792,19 @@ export class JsonApiBackend extends Backend {
|
|
|
790
792
|
params.set("filter[bundle]", bundle);
|
|
791
793
|
params.set("filter[field_name]", fieldName);
|
|
792
794
|
params.set("page[limit]", "1");
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
795
|
+
const read = async (resource) => {
|
|
796
|
+
try {
|
|
797
|
+
const data = await drupalFetch(this.site, `/jsonapi/${resource}/${resource}?${params}`);
|
|
798
|
+
const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
|
|
799
|
+
return parseFieldConfigObject(row?.attributes, fieldName);
|
|
800
|
+
} catch {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
// A configurable-field row is authoritative, including an empty
|
|
805
|
+
// allowed_formats list (no field-level restriction). Body is absent
|
|
806
|
+
// from field_config, so the base-field override is the next read.
|
|
807
|
+
return (await read("field_config")) ?? (await read("base_field_override"));
|
|
801
808
|
}
|
|
802
809
|
|
|
803
810
|
/**
|
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
* `FALLBACK_TEXT_FORMAT` (`full_html`).
|
|
8
8
|
*
|
|
9
9
|
* Resolution chain for a field definition:
|
|
10
|
-
* 1. `backend.getFieldDefinition` — JSON:API `field_config
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* 1. `backend.getFieldDefinition` — JSON:API `field_config`, then
|
|
11
|
+
* `base_field_override` (node body is a base field; its allowed_formats
|
|
12
|
+
* are not on `field_config`). Internal adapter fetch, not
|
|
13
|
+
* `drupal_entity_get` (`field_config` is on the agent deny list).
|
|
14
|
+
* 2. Drush `config:get field.field.{entityType}.{bundle}.{field}`, then
|
|
15
|
+
* `core.base_field_override.{entityType}.{bundle}.{field}`, when a Drush
|
|
16
|
+
* bridge is configured.
|
|
17
|
+
* 3. If the definition cannot be resolved: return null. Creates keep the
|
|
18
|
+
* historical default chain only while the list is unknown. Updates reuse
|
|
19
|
+
* the format already stored on that field (#327) so a dry run cannot
|
|
20
|
+
* preview a fallback the save will reject. Once the list is known, never
|
|
21
|
+
* persist a format outside it.
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
import { drushConfigured } from "./audit-sources.js";
|
|
@@ -36,10 +40,16 @@ const SKIP_FORMAT_FIELDS = new Set([
|
|
|
36
40
|
* @param {*} raw settings.allowed_formats from field_config / Drush.
|
|
37
41
|
* @returns {string[]}
|
|
38
42
|
*/
|
|
43
|
+
function isEnabledFormatId(value) {
|
|
44
|
+
// Field UI checkboxes store a disabled format as 0 or "0", and an enabled
|
|
45
|
+
// format as its machine name. "0" is a non-empty string but not a format.
|
|
46
|
+
return typeof value === "string" && value !== "" && value !== "0";
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
export function asFormatList(raw) {
|
|
40
|
-
if (Array.isArray(raw)) return raw.filter(
|
|
50
|
+
if (Array.isArray(raw)) return raw.filter(isEnabledFormatId);
|
|
41
51
|
if (raw && typeof raw === "object") {
|
|
42
|
-
return [...new Map(Object.entries(raw)).values()].filter(
|
|
52
|
+
return [...new Map(Object.entries(raw)).values()].filter(isEnabledFormatId);
|
|
43
53
|
}
|
|
44
54
|
return [];
|
|
45
55
|
}
|
|
@@ -98,13 +108,20 @@ export async function fieldDefinitionFromDrush(site, entityType, bundle, fieldNa
|
|
|
98
108
|
validateMachineName(entityType, "entityType");
|
|
99
109
|
validateMachineName(bundle, "bundle");
|
|
100
110
|
validateMachineName(fieldName, "fieldName");
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
const configNames = [
|
|
112
|
+
`field.field.${entityType}.${bundle}.${fieldName}`,
|
|
113
|
+
`core.base_field_override.${entityType}.${bundle}.${fieldName}`,
|
|
114
|
+
];
|
|
115
|
+
for (const configName of configNames) {
|
|
116
|
+
try {
|
|
117
|
+
const raw = parseDrush(await sshDrush(site, ["config:get", configName, "--format=json"]));
|
|
118
|
+
const parsed = parseFieldConfigObject(unwrapDrushConfig(raw, configName), fieldName);
|
|
119
|
+
if (parsed) return parsed;
|
|
120
|
+
} catch {
|
|
121
|
+
// A missing `field.field.*` row is how Drush reports a base field.
|
|
122
|
+
}
|
|
107
123
|
}
|
|
124
|
+
return null;
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
/**
|
|
@@ -225,6 +242,41 @@ function requestedFormatOf(value) {
|
|
|
225
242
|
return undefined;
|
|
226
243
|
}
|
|
227
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Format Drupal already stored for a field, if the canonical entity has one.
|
|
247
|
+
* @param {?object} entity
|
|
248
|
+
* @param {string} fieldName
|
|
249
|
+
* @returns {string|undefined}
|
|
250
|
+
*/
|
|
251
|
+
function storedTextFormat(entity, fieldName) {
|
|
252
|
+
const fields = entity?.fields;
|
|
253
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return undefined;
|
|
254
|
+
const value = new Map(Object.entries(fields)).get(fieldName);
|
|
255
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
256
|
+
const format = new Map(Object.entries(value)).get("format");
|
|
257
|
+
return typeof format === "string" && format && format !== "0" ? format : undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether a write attribute map omits a text format the resolver may have to
|
|
262
|
+
* supply. Used to decide whether an update must read the stored entity first.
|
|
263
|
+
* @param {?object} attributes
|
|
264
|
+
* @returns {boolean}
|
|
265
|
+
*/
|
|
266
|
+
export function attributesOmitTextFormat(attributes) {
|
|
267
|
+
if (!attributes || typeof attributes !== "object") return false;
|
|
268
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
269
|
+
if (SKIP_FORMAT_FIELDS.has(name) || value === undefined || value === null) continue;
|
|
270
|
+
if (typeof value === "string") return true;
|
|
271
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
272
|
+
const hasValue = Object.prototype.hasOwnProperty.call(value, "value");
|
|
273
|
+
const hasFormat = Object.prototype.hasOwnProperty.call(value, "format") && value.format;
|
|
274
|
+
if ((name === "body" || hasValue) && !hasFormat) return true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
228
280
|
/**
|
|
229
281
|
* @param {*} raw
|
|
230
282
|
* @param {string|undefined} format
|
|
@@ -247,11 +299,15 @@ function normalizeFormattedValue(raw, format) {
|
|
|
247
299
|
* Same checks run for dry-run and real writes so a disallowed format never
|
|
248
300
|
* reaches create/update.
|
|
249
301
|
*
|
|
250
|
-
*
|
|
302
|
+
* On update, pass `existingEntity`. When the allowed list is unknown and the
|
|
303
|
+
* caller omitted a format, the format already stored on that field is reused.
|
|
304
|
+
* The same choice is what dryRun previews and what the save sends (#327).
|
|
305
|
+
*
|
|
306
|
+
* @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object, existingEntity?: ?object}} input
|
|
251
307
|
* @returns {Promise<object>} The same attributes object, with formats resolved.
|
|
252
308
|
*/
|
|
253
309
|
export async function applyAllowedFormatsToAttributes({
|
|
254
|
-
backend, site, entityType, bundle, attributes,
|
|
310
|
+
backend, site, entityType, bundle, attributes, existingEntity = null,
|
|
255
311
|
}) {
|
|
256
312
|
const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
|
|
257
313
|
for (const fieldName of names) {
|
|
@@ -268,10 +324,17 @@ export async function applyAllowedFormatsToAttributes({
|
|
|
268
324
|
if (def && !formattedType && !restricted && !isBody) continue;
|
|
269
325
|
if (!def && !shaped && !isBody) continue;
|
|
270
326
|
|
|
327
|
+
const allowedFormats = def ? def.allowedFormats : null;
|
|
328
|
+
const listUnknown = !Array.isArray(allowedFormats) || allowedFormats.length === 0;
|
|
329
|
+
let requested = requestedFormatOf(value);
|
|
330
|
+
if ((requested === undefined || requested === null || requested === "") && listUnknown) {
|
|
331
|
+
const stored = storedTextFormat(existingEntity, fieldName);
|
|
332
|
+
if (stored) requested = stored;
|
|
333
|
+
}
|
|
271
334
|
const format = resolveTextFormat({
|
|
272
335
|
fieldName,
|
|
273
|
-
requested
|
|
274
|
-
allowedFormats
|
|
336
|
+
requested,
|
|
337
|
+
allowedFormats,
|
|
275
338
|
site,
|
|
276
339
|
defaultWhenUnknown: isBody,
|
|
277
340
|
});
|
package/src/lib/security.js
CHANGED
|
@@ -13,7 +13,7 @@ import { parse } from "graphql";
|
|
|
13
13
|
* ─── Quick presets ────────────────────────────────────────────────────────
|
|
14
14
|
*
|
|
15
15
|
* "preset": "development" Everything allowed. Opt-in only — set explicitly.
|
|
16
|
-
* "preset": "content-editor" Create/edit content (nodes, media, terms, paragraphs, blocks,
|
|
16
|
+
* "preset": "content-editor" Create/edit content (nodes, media, terms, paragraphs, library items, blocks,
|
|
17
17
|
* menu links, redirects, aliases, files). No deletes. Config read-only.
|
|
18
18
|
* "preset": "config-editor" content-editor + site-building config READ + governed config
|
|
19
19
|
* read/write (Developer tier). Model changes go via the config bridge.
|
|
@@ -99,6 +99,10 @@ import { parse } from "graphql";
|
|
|
99
99
|
// JSON:API-writable, so the standard entity tools create/update them directly.
|
|
100
100
|
const CONTENT_STRUCTURAL = [
|
|
101
101
|
"paragraph",
|
|
102
|
+
// Paragraphs Library items: reusable paragraphs placed on many pages. On a
|
|
103
|
+
// site that moderates them, the Drupal workflow and allowPublish both gate
|
|
104
|
+
// go-live; a publish-bearing write is refused here first.
|
|
105
|
+
"paragraphs_library_item",
|
|
102
106
|
"block_content",
|
|
103
107
|
"menu_link_content",
|
|
104
108
|
"redirect",
|
|
@@ -162,7 +166,8 @@ const PRESETS = {
|
|
|
162
166
|
allowConfigRead: true, // config read-only
|
|
163
167
|
allowConfigWrite: false,
|
|
164
168
|
// Full content building: base content types + structural content entities
|
|
165
|
-
// (paragraphs, custom blocks, menu links, redirects,
|
|
169
|
+
// (paragraphs, library items, custom blocks, menu links, redirects,
|
|
170
|
+
// aliases, files).
|
|
166
171
|
allowedEntityTypes: ["node", "media", "taxonomy_term", ...CONTENT_STRUCTURAL],
|
|
167
172
|
deniedEntityTypes: [...SENSITIVE_DENY],
|
|
168
173
|
entityRules: {
|
|
@@ -703,6 +708,32 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
|
|
|
703
708
|
}
|
|
704
709
|
}
|
|
705
710
|
|
|
711
|
+
// Entity types whose content is shared across many pages. When the tier
|
|
712
|
+
// cannot publish, a create that says nothing about publication is sent as
|
|
713
|
+
// unpublished instead of taking Drupal's published-by-default.
|
|
714
|
+
const UNPUBLISHED_BY_DEFAULT = new Set(["paragraphs_library_item"]);
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Attributes for a create on a tier that cannot publish.
|
|
718
|
+
*
|
|
719
|
+
* A Paragraphs Library item is published by default in Drupal. On a site that
|
|
720
|
+
* does not moderate library items, a create that omits `status` would put the
|
|
721
|
+
* item live on every page that places it, although allowPublish is false. For
|
|
722
|
+
* the shared types in UNPUBLISHED_BY_DEFAULT, add `status: false` when the
|
|
723
|
+
* caller set neither `status` nor `moderation_state`; on a moderated site the
|
|
724
|
+
* workflow's default state applies as before. Other types are unchanged.
|
|
725
|
+
*
|
|
726
|
+
* @param {object} secConfig Resolved security config.
|
|
727
|
+
* @param {string} entityType Entity type being created.
|
|
728
|
+
* @param {object} attributes Caller attributes.
|
|
729
|
+
* @returns {object} Attributes to send.
|
|
730
|
+
*/
|
|
731
|
+
export function withUnpublishedDefault(secConfig, entityType, attributes = {}) {
|
|
732
|
+
if (secConfig.allowPublish || !UNPUBLISHED_BY_DEFAULT.has(entityType)) return attributes;
|
|
733
|
+
if ("status" in attributes || "moderation_state" in attributes) return attributes;
|
|
734
|
+
return { ...attributes, status: false };
|
|
735
|
+
}
|
|
736
|
+
|
|
706
737
|
/**
|
|
707
738
|
* Detect whether a GraphQL document contains a mutation operation.
|
|
708
739
|
* Uses a real parser (robust against multi-operation docs, comments, and
|
package/src/lib/validate.js
CHANGED
|
@@ -196,26 +196,6 @@ export function validateBaseUrl(url, siteName = "site") {
|
|
|
196
196
|
return url.replace(/\/$/, "");
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
// Pagination limit guard
|
|
201
|
-
// Prevents accidentally requesting thousands of records in a single call.
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
|
|
204
|
-
const MAX_PAGE_LIMIT = 200;
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Clamp a page limit to a safe maximum, falling back to a default for
|
|
208
|
-
* non-numeric or out-of-range input.
|
|
209
|
-
* @param {*} value Requested limit (any type; coerced to Number).
|
|
210
|
-
* @param {number} [defaultVal] Value returned for invalid/<1 input.
|
|
211
|
-
* @returns {number} A limit in the range [1, MAX_PAGE_LIMIT].
|
|
212
|
-
*/
|
|
213
|
-
export function clampLimit(value, defaultVal = 20) {
|
|
214
|
-
const n = Number(value);
|
|
215
|
-
if (!Number.isFinite(n) || n < 1) return defaultVal;
|
|
216
|
-
return Math.min(n, MAX_PAGE_LIMIT);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
199
|
// ---------------------------------------------------------------------------
|
|
220
200
|
// Local file upload path allowlist (#137 / audit C1)
|
|
221
201
|
// ---------------------------------------------------------------------------
|
|
@@ -329,27 +309,3 @@ export function assertUploadPathAllowed(filePath) {
|
|
|
329
309
|
}
|
|
330
310
|
return real;
|
|
331
311
|
}
|
|
332
|
-
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
// Field name sanitization (prevent crafted field names in JSON:API filters)
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
|
|
337
|
-
const FIELD_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Validate a Drupal field machine name used in JSON:API filter parameters.
|
|
341
|
-
* Allows dotted paths (e.g. "field.subfield") for nested references.
|
|
342
|
-
* @param {string} value The field name to validate.
|
|
343
|
-
* @param {string} [fieldName] Human-readable name for error messages.
|
|
344
|
-
* @returns {string} The validated value.
|
|
345
|
-
* @throws {Error} if the value is not a valid field name.
|
|
346
|
-
*/
|
|
347
|
-
export function validateFieldName(value, fieldName = "field") {
|
|
348
|
-
if (typeof value !== "string" || !FIELD_NAME_RE.test(value)) {
|
|
349
|
-
throw new Error(
|
|
350
|
-
`${fieldName} "${value}" is not a valid field name. ` +
|
|
351
|
-
"Expected format: field_example or field.subfield"
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
return value;
|
|
355
|
-
}
|
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
* docs/module-workflows.md.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { sourceText
|
|
9
|
+
import { sourceText } from "./tool-prompts.js";
|
|
10
10
|
|
|
11
11
|
const ID_RE = /^[a-z][a-z0-9_]{0,47}$/;
|
|
12
12
|
const ARG_RE = /^[a-z][a-z0-9_]{0,47}$/;
|
|
13
|
-
const MAX_DESCRIPTION = 1024;
|
|
14
13
|
const MAX_INSTRUCTIONS = 8192;
|
|
15
14
|
const MAX_ARG_VALUE = 200;
|
|
16
15
|
const MAX_WORKFLOWS = 64;
|
|
@@ -465,26 +464,3 @@ export function replaceModuleWorkflows(workflows) {
|
|
|
465
464
|
export function lookupWorkflow(name) {
|
|
466
465
|
return INDEX.get(name);
|
|
467
466
|
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Index used by tests.
|
|
471
|
-
*
|
|
472
|
-
* @param {object[]} workflows
|
|
473
|
-
* @returns {Map<string, {readOnly: boolean, builtin: boolean, publicTools: string[]}>}
|
|
474
|
-
*/
|
|
475
|
-
export function workflowIndex(workflows) {
|
|
476
|
-
return new Map((workflows ?? []).map((wf) => [wf.name, {
|
|
477
|
-
readOnly: wf.readOnly,
|
|
478
|
-
builtin: wf.builtin,
|
|
479
|
-
publicTools: wf.publicTools,
|
|
480
|
-
}]));
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
export const WORKFLOW_LIMITS = {
|
|
484
|
-
MAX_DESCRIPTION,
|
|
485
|
-
MAX_INSTRUCTIONS,
|
|
486
|
-
MAX_WORKFLOWS,
|
|
487
|
-
};
|
|
488
|
-
|
|
489
|
-
// Re-export for callers that already import tool prompt hyphenation.
|
|
490
|
-
export { toolNameToPromptName };
|
package/src/tools/bulk.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { getSiteConfig } from "../lib/config.js";
|
|
15
15
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
16
|
-
import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
|
|
16
|
+
import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed, withUnpublishedDefault } from "../lib/security.js";
|
|
17
17
|
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
18
18
|
import {
|
|
19
19
|
resolveErrRelationships, embedParagraphRef,
|
|
@@ -56,7 +56,7 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
56
56
|
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
57
57
|
const entity = await backend.createEntity({
|
|
58
58
|
entityType, bundle,
|
|
59
|
-
attributes: item.attributes ?? {},
|
|
59
|
+
attributes: withUnpublishedDefault(sec, entityType, item.attributes ?? {}),
|
|
60
60
|
relationships: resolvedRelationships,
|
|
61
61
|
});
|
|
62
62
|
created += 1;
|
package/src/tools/entities.js
CHANGED
|
@@ -21,7 +21,7 @@ import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight
|
|
|
21
21
|
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
22
22
|
import {
|
|
23
23
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
24
|
-
redactCanonicalEntity, getSecuritySummary,
|
|
24
|
+
redactCanonicalEntity, getSecuritySummary, withUnpublishedDefault,
|
|
25
25
|
} from "../lib/security.js";
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -65,11 +65,12 @@ async function getEntity({ site: siteName, entityType, bundle, id, include = []
|
|
|
65
65
|
* @returns {Promise<object>} The created entity descriptor.
|
|
66
66
|
* @throws {SecurityError} If creating the type/bundle is not permitted.
|
|
67
67
|
*/
|
|
68
|
-
async function createEntity({ site: siteName, entityType, bundle, attributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
68
|
+
async function createEntity({ site: siteName, entityType, bundle, attributes: callerAttributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
69
69
|
const site = getSiteConfig(siteName);
|
|
70
70
|
const sec = resolveSecurityConfig(site);
|
|
71
71
|
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
72
|
-
assertPublishAllowed(sec,
|
|
72
|
+
assertPublishAllowed(sec, callerAttributes);
|
|
73
|
+
const attributes = withUnpublishedDefault(sec, entityType, callerAttributes);
|
|
73
74
|
const backend = await resolveBackend(site);
|
|
74
75
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
75
76
|
if (dryRun) {
|
package/src/tools/nodes.js
CHANGED
|
@@ -23,7 +23,7 @@ import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } f
|
|
|
23
23
|
import { paragraphResourceVersion } from "./paragraphs.js";
|
|
24
24
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
25
25
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
26
|
-
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
26
|
+
import { applyAllowedFormatsToAttributes, attributesOmitTextFormat } from "../lib/field-definition.js";
|
|
27
27
|
import { normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../lib/path-alias.js";
|
|
28
28
|
|
|
29
29
|
/** Fallback language for an alias when the node exposes none. */
|
|
@@ -469,19 +469,23 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
469
469
|
else if (status !== undefined) attributes.status = status;
|
|
470
470
|
const bodyAttr = buildBodyAttribute(body, summary, format);
|
|
471
471
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
//
|
|
472
|
+
// One pre-read serves the #131 draft default, the #171 unrequested-
|
|
473
|
+
// status-change flag, and the stored text format (#327). Skipped when the
|
|
474
|
+
// caller pinned the moderation state and every formatted value already
|
|
475
|
+
// names its format. An omitted format still reads the entity: the published
|
|
476
|
+
// node's core PATCH guard does not validate fields, so the preview has to
|
|
477
|
+
// resolve the same format the save will send before it can report success.
|
|
477
478
|
let existing = null;
|
|
478
|
-
if (!hasExplicitModerationState(attributes)) {
|
|
479
|
+
if (!hasExplicitModerationState(attributes) || attributesOmitTextFormat(attributes)) {
|
|
479
480
|
try {
|
|
480
481
|
existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
|
|
481
482
|
} catch {
|
|
482
483
|
existing = null; // Unreadable target: server-side gates stay authoritative.
|
|
483
484
|
}
|
|
484
485
|
}
|
|
486
|
+
await applyAllowedFormatsToAttributes({
|
|
487
|
+
backend, site, entityType: "node", bundle: type, attributes, existingEntity: existing,
|
|
488
|
+
});
|
|
485
489
|
// #131: published moderated nodes without an explicit state → draft forward revision.
|
|
486
490
|
// Runs before the publish gate and on dryRun so previews match the real write.
|
|
487
491
|
attributes = await applySafeDraftDefault({
|
|
@@ -632,7 +636,7 @@ export const definitions = [
|
|
|
632
636
|
title: { type: "string" },
|
|
633
637
|
body: { type: "string", description: "Body field HTML" },
|
|
634
638
|
summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
|
|
635
|
-
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When
|
|
639
|
+
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
|
|
636
640
|
status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
|
|
637
641
|
moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
|
|
638
642
|
fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
|
|
@@ -654,13 +658,13 @@ export const definitions = [
|
|
|
654
658
|
title: { type: "string" },
|
|
655
659
|
body: { type: "string" },
|
|
656
660
|
summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
|
|
657
|
-
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When
|
|
661
|
+
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before the preview and the write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, an update reuses the format already stored on the field; if the node has none, the site config's `defaultTextFormat`, then 'full_html'. dryRun previews that same format, including on the published-node core PATCH guard." },
|
|
658
662
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
659
663
|
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'." },
|
|
660
664
|
langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
|
|
661
665
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
|
|
662
666
|
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
|
|
663
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access
|
|
667
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
|
|
664
668
|
returning: RETURNING_SCHEMA,
|
|
665
669
|
},
|
|
666
670
|
},
|