drupal-mcp-connector 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/drupal-bulk-update.md +2 -2
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-entity-update.md +2 -2
- package/.claude/commands/drupal-update-node.md +3 -3
- package/.claude/commands/drupal-upload-file-and-create-media.md +4 -4
- package/.claude/commands/drupal-upload-file.md +1 -1
- package/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/scripts/generate-commands.js +4 -0
- package/src/index.js +27 -9
- package/src/lib/drupal-fetch.js +16 -6
- package/src/lib/link-checker.js +3 -1
- package/src/lib/moderation-default.js +106 -0
- package/src/lib/security.js +23 -13
- package/src/lib/validate.js +116 -0
- package/src/tools/bulk.js +13 -3
- package/src/tools/entities.js +12 -4
- package/src/tools/media.js +43 -13
- package/src/tools/moderation.js +7 -2
- package/src/tools/nodes.js +32 -9
- package/src/tools/taxonomy.js +10 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }."
|
|
2
|
+
description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }."
|
|
3
3
|
argument-hint: "<entityType> <bundle> <items> [site]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_bulk_update
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_bulk_update` MCP tool.
|
|
8
8
|
|
|
9
|
-
Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }.
|
|
9
|
+
Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -16,7 +16,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
16
16
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
|
-
- `status` (boolean (true/false))
|
|
19
|
+
- `status` (boolean (true/false)): Published flag. Defaults to false (unpublished). Requires allowPublish when true.
|
|
20
20
|
- `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')
|
|
21
21
|
|
|
22
22
|
If a required parameter is missing from `$ARGUMENTS`, 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.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change."
|
|
2
|
+
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)."
|
|
3
3
|
argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_entity_update
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_entity_update` MCP tool.
|
|
8
8
|
|
|
9
|
-
Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change.
|
|
9
|
+
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).
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`."
|
|
2
|
+
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`."
|
|
3
3
|
argument-hint: "<type> <id> [site] [title] [body] [summary] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_update_node
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_update_node` MCP tool.
|
|
8
8
|
|
|
9
|
-
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`.
|
|
9
|
+
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`.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -20,7 +20,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
20
20
|
- `body` (string)
|
|
21
21
|
- `summary` (string)
|
|
22
22
|
- `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
|
|
23
|
-
- `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status.
|
|
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
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
|
|
25
25
|
- `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 }, …] }.
|
|
26
26
|
- `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow."
|
|
2
|
+
description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished."
|
|
3
3
|
argument-hint: "<filePath> <mediaType> <fieldName> [site] [mediaName] [altText] [status]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_upload_file_and_create_media
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_upload_file_and_create_media` MCP tool.
|
|
8
8
|
|
|
9
|
-
Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow.
|
|
9
|
+
Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
13
13
|
**Required:**
|
|
14
|
-
- `filePath` (string):
|
|
14
|
+
- `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
|
|
15
15
|
- `mediaType` (string): Media type machine name, e.g. 'image'
|
|
16
16
|
- `fieldName` (string): Source field machine name, e.g. 'field_media_image'
|
|
17
17
|
|
|
@@ -19,6 +19,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
19
19
|
- `site` (string): omit for the default site
|
|
20
20
|
- `mediaName` (string): Name for the media entity (defaults to filename)
|
|
21
21
|
- `altText` (string): Alt text for image media
|
|
22
|
-
- `status` (boolean (true/false))
|
|
22
|
+
- `status` (boolean (true/false)): Published flag. Defaults to false. Requires allowPublish when true.
|
|
23
23
|
|
|
24
24
|
If a required parameter is missing from `$ARGUMENTS`, 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.
|
|
@@ -11,7 +11,7 @@ Upload a local file to Drupal and create a File entity. Returns the file UUID to
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
13
13
|
**Required:**
|
|
14
|
-
- `filePath` (string):
|
|
14
|
+
- `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
|
|
15
15
|
- `bundle` (string): Bundle machine name, e.g. 'image', 'article'
|
|
16
16
|
- `fieldName` (string): Field machine name, e.g. 'field_media_image', 'field_image'
|
|
17
17
|
|
package/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,67 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [2.1.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Specialized tools honor entity allowlists/denylists (#138).**
|
|
14
|
+
`drupal_*_node`, media, and taxonomy tools now call the same
|
|
15
|
+
`assertReadAllowed` / `assertWriteAllowed` / `assertDeleteAllowed` gates as
|
|
16
|
+
`drupal_entity_*`, so a denied type (e.g. `media`) cannot be reached by
|
|
17
|
+
switching tool names.
|
|
18
|
+
- **Upload path allowlist and path segment hardening (#137).** File uploads
|
|
19
|
+
must resolve under `MCP_UPLOAD_ROOT` (or the connector cwd by default);
|
|
20
|
+
entity/bundle/field path segments are machine-name validated and encoded;
|
|
21
|
+
Content-Disposition filenames are sanitized. Sensitive paths (`.env*`,
|
|
22
|
+
`.ssh`, connector `config.json`) are refused even under an allowed root.
|
|
23
|
+
- **Media no longer publishes by default (#139).** `drupal_create_media` and
|
|
24
|
+
upload-and-create default to `status: false` and honor `assertPublishAllowed`.
|
|
25
|
+
`moderation_state: published` and `drupal_set_moderation_state` to published
|
|
26
|
+
are treated as publish-bearing.
|
|
27
|
+
- **HTTPS fails closed without auth when non-loopback (#141).** Binding beyond
|
|
28
|
+
loopback requires `MCP_AUTH_TOKEN` unless `MCP_ALLOW_UNAUTHENTICATED=1`.
|
|
29
|
+
Non-loopback HTTPS defaults to 120 req/min rate limiting when
|
|
30
|
+
`MCP_RATE_LIMIT` is unset.
|
|
31
|
+
- **Link-checker does not follow redirects (#143).** Live checks use
|
|
32
|
+
`redirect: "manual"` to avoid SSRF via 302 to private/metadata targets.
|
|
33
|
+
- **auditor / production-strict apply SENSITIVE_DENY** (partial #140). Secrets,
|
|
34
|
+
governance, and account entity types stay denied on those presets. Default
|
|
35
|
+
when `security` is omitted remains `development` (tracked in #140).
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **Published moderated updates default to a draft forward revision (#131).**
|
|
39
|
+
`drupal_update_node`, `drupal_bulk_update`, and `drupal_entity_update` now
|
|
40
|
+
sniff the target entity: when it is published under content_moderation and
|
|
41
|
+
the caller omitted `moderation_state` / `moderationState`, the write is sent
|
|
42
|
+
as `moderation_state: draft` instead of omitting the field. That keeps bulk
|
|
43
|
+
relationship-wiring and field edits as reviewable forward revisions rather
|
|
44
|
+
than live default-revision mutations if a server-side publish gate
|
|
45
|
+
mis-classifies the write. Callers that want a same-state save must pass an
|
|
46
|
+
explicit moderation state.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
- **Provider-agnostic agent instructions.** Root `AGENTS.md` is the only
|
|
50
|
+
committed agent rule file (any coding agent). No vendor-specific
|
|
51
|
+
`.grok/` / `CLAUDE.md` / parallel rule trees. Generated slash stubs under
|
|
52
|
+
`.claude/commands/` remain client ergonomics for MCP operators, not project
|
|
53
|
+
development rules.
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
- Security, hardening, deployment, tools-reference, threat-model, and
|
|
57
|
+
architecture docs updated for the 2.1 controls (fail-closed HTTPS, upload
|
|
58
|
+
roots, publish/draft defaults, GraphQL policy caveat).
|
|
59
|
+
|
|
8
60
|
## [2.0.0] - 2026-07-29
|
|
9
61
|
|
|
62
|
+
### Fixed
|
|
63
|
+
- **Re-pushing a release tag no longer fails the publish job.** The workflow
|
|
64
|
+
now skips publishing a version already on the registry. Moving or recreating
|
|
65
|
+
a tag is a normal recovery operation, and npm's E403 "cannot publish over the
|
|
66
|
+
previously published version" reads as a broken release when the registry is
|
|
67
|
+
in exactly the state you wanted.
|
|
68
|
+
|
|
10
69
|
### Changed
|
|
11
70
|
- **BREAKING: `drupal_drush_sql_query` no longer runs ungoverned SQL, and is off
|
|
12
71
|
by default.** It called `drush sql:query`, which executes below Drupal's
|
package/package.json
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* `mcp__drupal__<tool>` and instructs the model to parse `$ARGUMENTS` into the
|
|
9
9
|
* tool's parameters before making a single call.
|
|
10
10
|
*
|
|
11
|
+
* These files are *client ergonomics* for operators using Claude Code as an MCP
|
|
12
|
+
* client against this server — not provider-specific project rules. Development
|
|
13
|
+
* instructions for any agent live only in AGENTS.md.
|
|
14
|
+
*
|
|
11
15
|
* Driven from the same tool definitions as the server (src/tools/index.js), so the
|
|
12
16
|
* command set never drifts from the tools. Run: `npm run generate:commands`.
|
|
13
17
|
*
|
package/src/index.js
CHANGED
|
@@ -384,6 +384,7 @@ if (transport === "stdio") {
|
|
|
384
384
|
|
|
385
385
|
const authToken = process.env.MCP_AUTH_TOKEN || "";
|
|
386
386
|
const checkAuth = makeBearerCheck(authToken);
|
|
387
|
+
const allowUnauth = process.env.MCP_ALLOW_UNAUTHENTICATED === "1";
|
|
387
388
|
if (!authToken) {
|
|
388
389
|
console.error(
|
|
389
390
|
"[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
|
|
@@ -450,11 +451,33 @@ if (transport === "stdio") {
|
|
|
450
451
|
return mcpTransport;
|
|
451
452
|
}
|
|
452
453
|
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
const
|
|
454
|
+
const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
|
|
455
|
+
// Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
|
|
456
|
+
// bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
|
|
457
|
+
const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
|
|
458
|
+
const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
|
|
459
|
+
|
|
460
|
+
// #141: fail closed when HTTPS is network-facing without a bearer token.
|
|
461
|
+
// Loopback binds and explicit MCP_ALLOW_UNAUTHENTICATED=1 remain for local/proxy setups.
|
|
462
|
+
if (!authToken && !isLoopbackBind && !allowUnauth) {
|
|
463
|
+
console.error(
|
|
464
|
+
"[drupal-mcp-connector] FATAL: MCP_AUTH_TOKEN is required when binding beyond loopback.\n" +
|
|
465
|
+
" Set MCP_AUTH_TOKEN, bind to 127.0.0.1 (default without MCP_BIND_HOST), or set\n" +
|
|
466
|
+
" MCP_ALLOW_UNAUTHENTICATED=1 only behind a trusted auth boundary."
|
|
467
|
+
);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Optional fixed-window rate limiting on /mcp, keyed by client IP.
|
|
472
|
+
// HTTPS non-loopback defaults to 120 req/min when MCP_RATE_LIMIT is unset (#141).
|
|
473
|
+
// Set MCP_RATE_LIMIT=0 to disable. Counts are per-process; multi-replica should
|
|
474
|
+
// also rate-limit at the reverse proxy.
|
|
457
475
|
const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
|
|
476
|
+
const rateLimitEnv = process.env.MCP_RATE_LIMIT;
|
|
477
|
+
const rateLimitDefault = (hasTls && !isLoopbackBind) ? 120 : 0;
|
|
478
|
+
const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
|
|
479
|
+
? rateLimitDefault
|
|
480
|
+
: Number(rateLimitEnv);
|
|
458
481
|
const rateLimiter = rateLimit > 0
|
|
459
482
|
? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
|
|
460
483
|
: null;
|
|
@@ -474,11 +497,6 @@ if (transport === "stdio") {
|
|
|
474
497
|
|
|
475
498
|
const nodeServer = createNodeServer(requestHandler);
|
|
476
499
|
|
|
477
|
-
const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
|
|
478
|
-
// Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
|
|
479
|
-
// bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
|
|
480
|
-
const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
|
|
481
|
-
|
|
482
500
|
nodeServer.listen(port, bindHost, () => {
|
|
483
501
|
const proto = hasTls ? "https" : "http";
|
|
484
502
|
console.error(
|
package/src/lib/drupal-fetch.js
CHANGED
|
@@ -7,6 +7,11 @@ import { createReadStream, statSync } from "fs";
|
|
|
7
7
|
import { basename } from "path";
|
|
8
8
|
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
9
9
|
import { clearToken } from "./oauth.js";
|
|
10
|
+
import {
|
|
11
|
+
assertUploadPathAllowed,
|
|
12
|
+
sanitizeUploadFilename,
|
|
13
|
+
validateMachineName,
|
|
14
|
+
} from "./validate.js";
|
|
10
15
|
|
|
11
16
|
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
12
17
|
|
|
@@ -115,10 +120,15 @@ export async function drupalGraphqlFetch(site, body) {
|
|
|
115
120
|
* @throws {Error} on any non-2xx response.
|
|
116
121
|
*/
|
|
117
122
|
export async function drupalUploadFile(site, entityType, bundle, fieldName, filePath) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
// #137: machine-name segments + path allowlist before any FS or network I/O.
|
|
124
|
+
validateMachineName(entityType, "entityType");
|
|
125
|
+
validateMachineName(bundle, "bundle");
|
|
126
|
+
validateMachineName(fieldName, "fieldName");
|
|
127
|
+
const safePath = assertUploadPathAllowed(filePath);
|
|
128
|
+
const filename = sanitizeUploadFilename(basename(safePath));
|
|
129
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
|
|
130
|
+
const stat = statSync(safePath);
|
|
131
|
+
const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
|
|
122
132
|
|
|
123
133
|
const res = await fetch(url, {
|
|
124
134
|
method: "POST",
|
|
@@ -129,8 +139,8 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
129
139
|
...clientHeaders(),
|
|
130
140
|
...(await authHeadersAsync(site)),
|
|
131
141
|
},
|
|
132
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename --
|
|
133
|
-
body: createReadStream(
|
|
142
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
|
|
143
|
+
body: createReadStream(safePath),
|
|
134
144
|
// node-fetch requires explicit size for streams to set Content-Length
|
|
135
145
|
size: stat.size,
|
|
136
146
|
});
|
package/src/lib/link-checker.js
CHANGED
|
@@ -190,7 +190,9 @@ async function timedFetch(url, method, { timeoutMs, fetchImpl }) {
|
|
|
190
190
|
const controller = new AbortController();
|
|
191
191
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
192
192
|
try {
|
|
193
|
-
|
|
193
|
+
// #143: never auto-follow redirects — a public allowlisted host can 302 to
|
|
194
|
+
// private/metadata. Report the redirect status; do not chase Location.
|
|
195
|
+
const res = await fetchImpl(url, { method, redirect: "manual", signal: controller.signal });
|
|
194
196
|
return { status: res.status, error: null };
|
|
195
197
|
} catch (err) {
|
|
196
198
|
const reason = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : (err?.message || "request failed");
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe draft default for updates of published, moderated entities (#131).
|
|
3
|
+
*
|
|
4
|
+
* When a PATCH targets a *published* entity under content_moderation and the
|
|
5
|
+
* caller did not pass an explicit `moderation_state`, the write can land on the
|
|
6
|
+
* live default revision if a server-side publish gate mis-classifies it as "no
|
|
7
|
+
* transition". Defaulting to `moderation_state: draft` forces a forward
|
|
8
|
+
* (reviewable) revision instead.
|
|
9
|
+
*
|
|
10
|
+
* Callers that genuinely want a same-state save on a published node must opt in
|
|
11
|
+
* by passing `moderation_state` (or the tool-level `moderationState`) explicitly.
|
|
12
|
+
*
|
|
13
|
+
* Detection is best-effort from the existing entity payload: the entity is
|
|
14
|
+
* treated as moderated when a `moderation_state` field is present on it, and as
|
|
15
|
+
* published when `status === true`. If the entity cannot be read, attributes
|
|
16
|
+
* are left unchanged (fail open for non-moderated bundles; server-side gate
|
|
17
|
+
* remains authoritative).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Default moderation state applied when none was supplied for a published moderated entity. */
|
|
21
|
+
export const SAFE_DRAFT_STATE = "draft";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a canonical entity exposes a content_moderation state field.
|
|
25
|
+
* @param {?object} entity Canonical entity (or null).
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function entityLooksModerated(entity) {
|
|
29
|
+
if (!entity) return false;
|
|
30
|
+
if (entity.fields && Object.prototype.hasOwnProperty.call(entity.fields, "moderation_state")) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// Tolerate a top-level promotion if a backend ever surfaces it that way.
|
|
34
|
+
return Object.prototype.hasOwnProperty.call(entity, "moderation_state");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a canonical entity is currently published.
|
|
39
|
+
* @param {?object} entity Canonical entity (or null).
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isPublishedEntity(entity) {
|
|
43
|
+
return entity?.status === true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the write attributes already carry an explicit moderation state.
|
|
48
|
+
* @param {?object} attributes Attribute map about to be sent.
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function hasExplicitModerationState(attributes) {
|
|
52
|
+
return Boolean(attributes && Object.prototype.hasOwnProperty.call(attributes, "moderation_state"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether a draft default should be injected for this update.
|
|
57
|
+
* Pure helper — no I/O.
|
|
58
|
+
*
|
|
59
|
+
* @param {{attributes?: object, entity?: ?object}} input
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function shouldDefaultPublishedUpdateToDraft({ attributes, entity }) {
|
|
63
|
+
if (hasExplicitModerationState(attributes)) return false;
|
|
64
|
+
if (!isPublishedEntity(entity)) return false;
|
|
65
|
+
if (!entityLooksModerated(entity)) return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply the safe draft default when updating a published moderated entity.
|
|
71
|
+
* Returns a new attributes object when a default is applied; otherwise returns
|
|
72
|
+
* the original attributes reference unchanged.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} args
|
|
75
|
+
* @param {object} args.backend Backend with `getEntity`.
|
|
76
|
+
* @param {string} args.entityType
|
|
77
|
+
* @param {string} args.bundle
|
|
78
|
+
* @param {string} args.id Entity UUID.
|
|
79
|
+
* @param {object} [args.attributes={}] Attribute map for the write.
|
|
80
|
+
* @param {?object} [args.existingEntity] Pre-fetched entity; when omitted a get is issued.
|
|
81
|
+
* @returns {Promise<object>} Attributes to send (possibly with moderation_state: draft).
|
|
82
|
+
*/
|
|
83
|
+
export async function applySafeDraftDefault({
|
|
84
|
+
backend,
|
|
85
|
+
entityType,
|
|
86
|
+
bundle,
|
|
87
|
+
id,
|
|
88
|
+
attributes = {},
|
|
89
|
+
existingEntity,
|
|
90
|
+
}) {
|
|
91
|
+
if (hasExplicitModerationState(attributes)) return attributes;
|
|
92
|
+
|
|
93
|
+
let entity = existingEntity;
|
|
94
|
+
if (entity === undefined) {
|
|
95
|
+
try {
|
|
96
|
+
entity = await backend.getEntity({ entityType, bundle, id });
|
|
97
|
+
} catch {
|
|
98
|
+
// Fail open: without a readable target we cannot sniff moderation.
|
|
99
|
+
return attributes;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!shouldDefaultPublishedUpdateToDraft({ attributes, entity })) {
|
|
103
|
+
return attributes;
|
|
104
|
+
}
|
|
105
|
+
return { ...attributes, moderation_state: SAFE_DRAFT_STATE };
|
|
106
|
+
}
|
package/src/lib/security.js
CHANGED
|
@@ -175,7 +175,8 @@ const PRESETS = {
|
|
|
175
175
|
allowConfigRead: true, // read-only inspection of config
|
|
176
176
|
allowConfigWrite: false,
|
|
177
177
|
allowedEntityTypes: null, // read any entity type
|
|
178
|
-
|
|
178
|
+
// Secrets / governance / account entities stay denied even on broad read (#140).
|
|
179
|
+
deniedEntityTypes: [...SENSITIVE_DENY],
|
|
179
180
|
entityRules: {
|
|
180
181
|
user: {
|
|
181
182
|
allowedOperations: ["read"],
|
|
@@ -192,7 +193,7 @@ const PRESETS = {
|
|
|
192
193
|
allowConfigRead: false, // nothing implicit; opt in per site
|
|
193
194
|
allowConfigWrite: false,
|
|
194
195
|
allowedEntityTypes: null, // set an explicit allowlist in your config
|
|
195
|
-
deniedEntityTypes: [
|
|
196
|
+
deniedEntityTypes: [...SENSITIVE_DENY], // includes user + secrets/governance
|
|
196
197
|
entityRules: {},
|
|
197
198
|
globalRedactedFields: ["pass", "mail", "field_private", "field_api_key", "field_token"],
|
|
198
199
|
},
|
|
@@ -379,23 +380,33 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
|
|
|
379
380
|
}
|
|
380
381
|
|
|
381
382
|
/**
|
|
382
|
-
* Whether a set of write attributes carries a publish action.
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
383
|
+
* Whether a set of write attributes carries a publish action.
|
|
384
|
+
*
|
|
385
|
+
* Recognized signals:
|
|
386
|
+
* - `status === true` (non-moderated publish)
|
|
387
|
+
* - `moderation_state` equal to `"published"` (case-insensitive) — the common
|
|
388
|
+
* core content_moderation publish state (#139)
|
|
389
|
+
*
|
|
390
|
+
* Other workflow state names are site-specific and remain server-gated.
|
|
386
391
|
* @param {object} [attributes] Attribute map for the write.
|
|
387
392
|
* @returns {boolean}
|
|
388
393
|
*/
|
|
389
394
|
export function isPublishBearing(attributes = {}) {
|
|
390
|
-
|
|
395
|
+
if (attributes?.status === true) return true;
|
|
396
|
+
// Common core content_moderation publish state. Site-specific publish state
|
|
397
|
+
// names remain server-gated; this closes the obvious connector-layer hole (#139).
|
|
398
|
+
const mod = attributes?.moderation_state;
|
|
399
|
+
if (typeof mod === "string" && mod.toLowerCase() === "published") return true;
|
|
400
|
+
return false;
|
|
391
401
|
}
|
|
392
402
|
|
|
393
403
|
/**
|
|
394
404
|
* Local, fail-fast publish gate, symmetric with assertDestructiveAllowed. When
|
|
395
405
|
* the connector is not permitted to publish (allowPublish false — the default in
|
|
396
|
-
* every preset except `development`), a write
|
|
397
|
-
*
|
|
398
|
-
*
|
|
406
|
+
* every preset except `development`), a write that is publish-bearing
|
|
407
|
+
* (`status: true` or `moderation_state: "published"`) is refused before the
|
|
408
|
+
* round-trip, rather than being silently dropped by a moderated-bundle retry or
|
|
409
|
+
* a server-side gate (see #111/#114/#139). Client-side convenience only — the
|
|
399
410
|
* remote Drupal's own permissions remain the real authority.
|
|
400
411
|
* @param {object} secConfig Resolved security config.
|
|
401
412
|
* @param {object} [attributes] Attribute map for the write.
|
|
@@ -407,9 +418,8 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
|
|
|
407
418
|
if (isPublishBearing(attributes)) {
|
|
408
419
|
throw new SecurityError(
|
|
409
420
|
"Publishing is disabled for this connector (allowPublish: false). " +
|
|
410
|
-
"Blocked: a write carrying status:true. " +
|
|
411
|
-
"To enable, set security.allowPublish = true in your config
|
|
412
|
-
"via an operator/server-gated path (e.g. a moderation transition)."
|
|
421
|
+
"Blocked: a write carrying status:true or moderation_state:published. " +
|
|
422
|
+
"To enable, set security.allowPublish = true in your config."
|
|
413
423
|
);
|
|
414
424
|
}
|
|
415
425
|
}
|
package/src/lib/validate.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* coerce values in ways that could hide injection attempts.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { existsSync, realpathSync } from "fs";
|
|
12
|
+
import { basename, resolve, sep } from "path";
|
|
11
13
|
import { SecurityError } from "./security.js";
|
|
12
14
|
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
@@ -214,6 +216,120 @@ export function clampLimit(value, defaultVal = 20) {
|
|
|
214
216
|
return Math.min(n, MAX_PAGE_LIMIT);
|
|
215
217
|
}
|
|
216
218
|
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Local file upload path allowlist (#137 / audit C1)
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
//
|
|
223
|
+
// Upload tools must not be able to read arbitrary process-readable files
|
|
224
|
+
// (SSH keys, .env, connector config). Paths must resolve under an allowed
|
|
225
|
+
// root. Default root is process.cwd(); operators expand with MCP_UPLOAD_ROOT
|
|
226
|
+
// (colon- or semicolon-separated absolute paths).
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resolve an absolute path, following symlinks when the target exists.
|
|
230
|
+
* @param {string} p Path to resolve.
|
|
231
|
+
* @returns {string} Canonical absolute path.
|
|
232
|
+
*/
|
|
233
|
+
function resolveExistingPath(p) {
|
|
234
|
+
const abs = resolve(p);
|
|
235
|
+
try {
|
|
236
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- operator-configured upload root or cwd
|
|
237
|
+
return realpathSync(abs);
|
|
238
|
+
} catch {
|
|
239
|
+
return abs;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Allowed upload roots. From MCP_UPLOAD_ROOT when set; otherwise process.cwd().
|
|
245
|
+
* @returns {string[]} Absolute root paths (realpath when possible).
|
|
246
|
+
*/
|
|
247
|
+
export function getUploadRoots() {
|
|
248
|
+
const raw = process.env.MCP_UPLOAD_ROOT;
|
|
249
|
+
if (raw && String(raw).trim()) {
|
|
250
|
+
return String(raw)
|
|
251
|
+
.split(/[:;]/)
|
|
252
|
+
.map((s) => s.trim())
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.map(resolveExistingPath);
|
|
255
|
+
}
|
|
256
|
+
return [resolveExistingPath(process.cwd())];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Sanitize a filename for Content-Disposition (no CR/LF/quotes; bounded length).
|
|
261
|
+
* @param {string} name Raw filename (often basename of a path).
|
|
262
|
+
* @returns {string} Safe filename for the header.
|
|
263
|
+
*/
|
|
264
|
+
export function sanitizeUploadFilename(name) {
|
|
265
|
+
const base = basename(String(name || "upload"));
|
|
266
|
+
const cleaned = base
|
|
267
|
+
.replace(/[\r\n"\\]/g, "")
|
|
268
|
+
.replace(/[^\w.\- ()[\]]+/g, "_")
|
|
269
|
+
.replace(/^\.+/, "")
|
|
270
|
+
.slice(0, 200);
|
|
271
|
+
return cleaned || "upload.bin";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Whether a resolved path sits under a sensitive location that must never
|
|
276
|
+
* be uploaded even when it is inside an allowed root.
|
|
277
|
+
* @param {string} real Absolute real path.
|
|
278
|
+
* @returns {boolean}
|
|
279
|
+
*/
|
|
280
|
+
function isSensitiveUploadPath(real) {
|
|
281
|
+
const n = real.split(sep).join("/");
|
|
282
|
+
if (n.includes("/.ssh/") || n.endsWith("/.ssh") || n.includes("/.gnupg/")) return true;
|
|
283
|
+
const base = basename(real);
|
|
284
|
+
if (base === "id_rsa" || base === "id_ed25519" || base === "id_ecdsa" || base === "id_dsa") return true;
|
|
285
|
+
if (base.startsWith(".env")) return true;
|
|
286
|
+
if (n.endsWith("/config/config.json")) return true;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Validate that a local file path may be uploaded: exists, under an allowed
|
|
292
|
+
* root, and not a known secret path. Returns the resolved real path for use
|
|
293
|
+
* with fs streams.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} filePath Caller-supplied path.
|
|
296
|
+
* @returns {string} Real absolute path safe to read for upload.
|
|
297
|
+
* @throws {Error} if the path is missing or not a string.
|
|
298
|
+
* @throws {SecurityError} if the path is outside roots or sensitive.
|
|
299
|
+
*/
|
|
300
|
+
export function assertUploadPathAllowed(filePath) {
|
|
301
|
+
if (typeof filePath !== "string" || !filePath.trim()) {
|
|
302
|
+
throw new Error("filePath must be a non-empty string.");
|
|
303
|
+
}
|
|
304
|
+
const abs = resolve(filePath);
|
|
305
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path is caller-supplied; allowlist enforced after realpath
|
|
306
|
+
if (!existsSync(abs)) {
|
|
307
|
+
throw new Error(`Upload path does not exist: ${filePath}`);
|
|
308
|
+
}
|
|
309
|
+
let real;
|
|
310
|
+
try {
|
|
311
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path is caller-supplied; allowlist enforced next
|
|
312
|
+
real = realpathSync(abs);
|
|
313
|
+
} catch {
|
|
314
|
+
throw new Error(`Upload path is not readable: ${filePath}`);
|
|
315
|
+
}
|
|
316
|
+
const roots = getUploadRoots();
|
|
317
|
+
const allowed = roots.some((root) => real === root || real.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
318
|
+
if (!allowed) {
|
|
319
|
+
throw new SecurityError(
|
|
320
|
+
"Upload path is outside allowed roots. Set MCP_UPLOAD_ROOT to an absolute " +
|
|
321
|
+
"directory (or several, colon-separated), or place the file under the " +
|
|
322
|
+
`connector working directory. Denied: ${filePath}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (isSensitiveUploadPath(real)) {
|
|
326
|
+
throw new SecurityError(
|
|
327
|
+
"Upload of sensitive paths (SSH keys, .env*, connector config.json) is not allowed."
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return real;
|
|
331
|
+
}
|
|
332
|
+
|
|
217
333
|
// ---------------------------------------------------------------------------
|
|
218
334
|
// Field name sanitization (prevent crafted field names in JSON:API filters)
|
|
219
335
|
// ---------------------------------------------------------------------------
|
package/src/tools/bulk.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
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
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Normalize an unknown thrown value into a human-readable message.
|
|
@@ -67,6 +68,11 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
67
68
|
* each item is updated independently so the batch continues past failures. An
|
|
68
69
|
* item missing an id is reported as a per-item failure rather than aborting.
|
|
69
70
|
*
|
|
71
|
+
* Safe default (#131): each published moderated target without an explicit
|
|
72
|
+
* `moderation_state` is rewritten to `moderation_state: draft` before the
|
|
73
|
+
* PATCH, so bulk relationship-wiring and field edits become forward revisions
|
|
74
|
+
* rather than live default-revision mutations.
|
|
75
|
+
*
|
|
70
76
|
* @param {object} args - { site?, entityType, bundle, items: [{ id, attributes?, relationships? }] }.
|
|
71
77
|
* @returns {Promise<{results: object[], summary: {updated: number, failed: number}}>}
|
|
72
78
|
* Per-item { index, success, id? | error } plus a roll-up summary.
|
|
@@ -85,10 +91,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
85
91
|
const item = rawItem || {};
|
|
86
92
|
try {
|
|
87
93
|
if (!item.id) throw new Error("Missing 'id' for update item");
|
|
88
|
-
|
|
94
|
+
const attributes = await applySafeDraftDefault({
|
|
95
|
+
backend, entityType, bundle, id: item.id,
|
|
96
|
+
attributes: item.attributes ?? {},
|
|
97
|
+
});
|
|
98
|
+
assertPublishAllowed(sec, attributes);
|
|
89
99
|
const entity = await backend.updateEntity({
|
|
90
100
|
entityType, bundle, id: item.id,
|
|
91
|
-
attributes
|
|
101
|
+
attributes,
|
|
92
102
|
relationships: item.relationships ?? {},
|
|
93
103
|
});
|
|
94
104
|
updated += 1;
|
|
@@ -130,7 +140,7 @@ export const definitions = [
|
|
|
130
140
|
},
|
|
131
141
|
{
|
|
132
142
|
name: "drupal_bulk_update",
|
|
133
|
-
description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }.",
|
|
143
|
+
description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }.",
|
|
134
144
|
inputSchema: {
|
|
135
145
|
type: "object", required: ["entityType", "bundle", "items"],
|
|
136
146
|
properties: {
|
package/src/tools/entities.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
13
|
import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
14
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
14
15
|
import {
|
|
15
16
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
16
17
|
redactCanonicalEntity, getSecuritySummary,
|
|
@@ -70,6 +71,10 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
70
71
|
/**
|
|
71
72
|
* Update an entity of any type/bundle (partial — only supplied fields are sent).
|
|
72
73
|
*
|
|
74
|
+
* Safe default (#131): published moderated targets without an explicit
|
|
75
|
+
* `moderation_state` get `moderation_state: draft` so the write is a forward
|
|
76
|
+
* revision rather than a live default-revision mutation.
|
|
77
|
+
*
|
|
73
78
|
* @param {object} args - { site?, entityType, bundle, id, attributes?, relationships? }.
|
|
74
79
|
* @returns {Promise<object>} The updated entity descriptor.
|
|
75
80
|
* @throws {SecurityError} If updating the type/bundle is not permitted.
|
|
@@ -78,10 +83,13 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
78
83
|
const site = getSiteConfig(siteName);
|
|
79
84
|
const sec = resolveSecurityConfig(site);
|
|
80
85
|
assertWriteAllowed(sec, "update", entityType, bundle);
|
|
81
|
-
assertPublishAllowed(sec, attributes);
|
|
82
|
-
if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes, relationships };
|
|
83
86
|
const backend = await resolveBackend(site);
|
|
84
|
-
|
|
87
|
+
const safeAttributes = await applySafeDraftDefault({
|
|
88
|
+
backend, entityType, bundle, id, attributes,
|
|
89
|
+
});
|
|
90
|
+
assertPublishAllowed(sec, safeAttributes);
|
|
91
|
+
if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes: safeAttributes, relationships };
|
|
92
|
+
return shapeWriteResponse(await backend.updateEntity({ entityType, bundle, id, attributes: safeAttributes, relationships }), returning);
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
/**
|
|
@@ -222,7 +230,7 @@ export const definitions = [
|
|
|
222
230
|
},
|
|
223
231
|
{
|
|
224
232
|
name: "drupal_entity_update",
|
|
225
|
-
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change.",
|
|
233
|
+
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).",
|
|
226
234
|
inputSchema: {
|
|
227
235
|
type: "object", required: ["entityType", "bundle", "id"],
|
|
228
236
|
properties: {
|
package/src/tools/media.js
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { getSiteConfig } from "../lib/config.js";
|
|
10
10
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
resolveSecurityConfig, redactCanonicalEntity,
|
|
13
|
+
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
14
|
+
} from "../lib/security.js";
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* List all media types (bundles of the media entity type).
|
|
@@ -17,6 +20,7 @@ import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js
|
|
|
17
20
|
*/
|
|
18
21
|
async function listMediaTypes({ site: siteName }) {
|
|
19
22
|
const site = getSiteConfig(siteName);
|
|
23
|
+
assertReadAllowed(resolveSecurityConfig(site), "media");
|
|
20
24
|
const backend = await resolveBackend(site);
|
|
21
25
|
return backend.listBundles("media");
|
|
22
26
|
}
|
|
@@ -32,11 +36,13 @@ async function listMediaTypes({ site: siteName }) {
|
|
|
32
36
|
async function listMedia({ site: siteName, type, status, name, limit = 20, offset = 0 }) {
|
|
33
37
|
const site = getSiteConfig(siteName);
|
|
34
38
|
const sec = resolveSecurityConfig(site);
|
|
39
|
+
const bundle = type || "image";
|
|
40
|
+
assertReadAllowed(sec, "media", bundle);
|
|
35
41
|
const backend = await resolveBackend(site);
|
|
36
42
|
const filters = [];
|
|
37
43
|
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
38
44
|
if (name) filters.push({ field: "name", op: "contains", value: name });
|
|
39
|
-
const res = await backend.listEntities({ entityType: "media", bundle
|
|
45
|
+
const res = await backend.listEntities({ entityType: "media", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit, offset } });
|
|
40
46
|
const items = res.entities.map((e) => redactCanonicalEntity(e, sec, "media"));
|
|
41
47
|
return { total: res.page?.total ?? items.length, approximate: res.approximate ?? false, offset, nextOffset: offset + items.length, media: items };
|
|
42
48
|
}
|
|
@@ -49,6 +55,7 @@ async function listMedia({ site: siteName, type, status, name, limit = 20, offse
|
|
|
49
55
|
async function getMedia({ site: siteName, type, id }) {
|
|
50
56
|
const site = getSiteConfig(siteName);
|
|
51
57
|
const sec = resolveSecurityConfig(site);
|
|
58
|
+
assertReadAllowed(sec, "media", type);
|
|
52
59
|
const backend = await resolveBackend(site);
|
|
53
60
|
const entity = await backend.getEntity({ entityType: "media", bundle: type, id });
|
|
54
61
|
return entity ? redactCanonicalEntity(entity, sec, "media") : null;
|
|
@@ -56,15 +63,23 @@ async function getMedia({ site: siteName, type, id }) {
|
|
|
56
63
|
|
|
57
64
|
/**
|
|
58
65
|
* Create a media entity. Caller `fields` are spread into attributes; name and
|
|
59
|
-
* status are layered on top.
|
|
66
|
+
* status are layered on top. Defaults to unpublished (`status: false`) so
|
|
67
|
+
* media is never auto-published under non-publishing presets (#139).
|
|
60
68
|
*
|
|
61
69
|
* @param {object} args - { site?, type, name, status?, fields? }.
|
|
62
70
|
* @returns {Promise<object>} The created media descriptor.
|
|
63
71
|
*/
|
|
64
|
-
async function createMedia({ site: siteName, type, name, status =
|
|
72
|
+
async function createMedia({ site: siteName, type, name, status = false, fields = {} }) {
|
|
65
73
|
const site = getSiteConfig(siteName);
|
|
74
|
+
const sec = resolveSecurityConfig(site);
|
|
75
|
+
assertWriteAllowed(sec, "create", "media", type);
|
|
76
|
+
const attributes = { name, status, ...fields };
|
|
77
|
+
// Layer name/status after fields so they win, matching prior behaviour.
|
|
78
|
+
attributes.name = name;
|
|
79
|
+
attributes.status = status;
|
|
80
|
+
assertPublishAllowed(sec, attributes);
|
|
66
81
|
const backend = await resolveBackend(site);
|
|
67
|
-
return backend.createEntity({ entityType: "media", bundle: type, attributes
|
|
82
|
+
return backend.createEntity({ entityType: "media", bundle: type, attributes });
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
/**
|
|
@@ -74,10 +89,13 @@ async function createMedia({ site: siteName, type, name, status = true, fields =
|
|
|
74
89
|
*/
|
|
75
90
|
async function updateMedia({ site: siteName, type, id, name, status, fields = {} }) {
|
|
76
91
|
const site = getSiteConfig(siteName);
|
|
77
|
-
const
|
|
92
|
+
const sec = resolveSecurityConfig(site);
|
|
93
|
+
assertWriteAllowed(sec, "update", "media", type);
|
|
78
94
|
const attributes = { ...fields };
|
|
79
95
|
if (name !== undefined) attributes.name = name;
|
|
80
96
|
if (status !== undefined) attributes.status = status;
|
|
97
|
+
assertPublishAllowed(sec, attributes);
|
|
98
|
+
const backend = await resolveBackend(site);
|
|
81
99
|
return backend.updateEntity({ entityType: "media", bundle: type, id, attributes });
|
|
82
100
|
}
|
|
83
101
|
|
|
@@ -90,6 +108,7 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
|
|
|
90
108
|
*/
|
|
91
109
|
async function deleteMedia({ site: siteName, type, id }) {
|
|
92
110
|
const site = getSiteConfig(siteName);
|
|
111
|
+
assertDeleteAllowed(resolveSecurityConfig(site), "media", type, id);
|
|
93
112
|
const backend = await resolveBackend(site);
|
|
94
113
|
await backend.deleteEntity({ entityType: "media", bundle: type, id });
|
|
95
114
|
return { success: true, deletedId: id };
|
|
@@ -104,6 +123,10 @@ async function deleteMedia({ site: siteName, type, id }) {
|
|
|
104
123
|
*/
|
|
105
124
|
async function uploadFile({ site: siteName, filePath, entityType = "media", bundle, fieldName }) {
|
|
106
125
|
const site = getSiteConfig(siteName);
|
|
126
|
+
const sec = resolveSecurityConfig(site);
|
|
127
|
+
// Host entity field write + file create (both required for JSON:API upload).
|
|
128
|
+
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
129
|
+
assertWriteAllowed(sec, "create", "file");
|
|
107
130
|
const backend = await resolveBackend(site);
|
|
108
131
|
return backend.uploadFile({ entityType, bundle, fieldName, filePath });
|
|
109
132
|
}
|
|
@@ -118,8 +141,14 @@ async function uploadFile({ site: siteName, filePath, entityType = "media", bund
|
|
|
118
141
|
* @returns {Promise<{file: {id: string, filename: string}, media: object}>}
|
|
119
142
|
* @throws {BackendCapabilityError} If the backend cannot upload files.
|
|
120
143
|
*/
|
|
121
|
-
async function uploadFileAndCreateMedia({ site: siteName, filePath, mediaType, mediaName, fieldName, altText, status =
|
|
144
|
+
async function uploadFileAndCreateMedia({ site: siteName, filePath, mediaType, mediaName, fieldName, altText, status = false }) {
|
|
122
145
|
const site = getSiteConfig(siteName);
|
|
146
|
+
const sec = resolveSecurityConfig(site);
|
|
147
|
+
assertWriteAllowed(sec, "create", "media", mediaType);
|
|
148
|
+
assertWriteAllowed(sec, "create", "file");
|
|
149
|
+
const attributes = { name: mediaName || undefined, status };
|
|
150
|
+
// Gate publish before any upload I/O so a blocked publish does not leave an orphan file.
|
|
151
|
+
assertPublishAllowed(sec, attributes);
|
|
123
152
|
const backend = await resolveBackend(site);
|
|
124
153
|
const file = await backend.uploadFile({ entityType: "media", bundle: mediaType, fieldName, filePath });
|
|
125
154
|
const fileData = altText
|
|
@@ -144,8 +173,9 @@ async function uploadFileAndCreateMedia({ site: siteName, filePath, mediaType, m
|
|
|
144
173
|
async function findOrphanedMedia({ site: siteName, type, limit = 50 }) {
|
|
145
174
|
const site = getSiteConfig(siteName);
|
|
146
175
|
const sec = resolveSecurityConfig(site);
|
|
147
|
-
const backend = await resolveBackend(site);
|
|
148
176
|
const bundle = type || "image";
|
|
177
|
+
assertReadAllowed(sec, "media", bundle);
|
|
178
|
+
const backend = await resolveBackend(site);
|
|
149
179
|
const map = (res, method, note) => ({
|
|
150
180
|
method, ...(note ? { note } : {}), count: res.entities.length,
|
|
151
181
|
media: res.entities.map((e) => redactCanonicalEntity(e, sec, "media")),
|
|
@@ -208,7 +238,7 @@ export const definitions = [
|
|
|
208
238
|
site: { type: "string" },
|
|
209
239
|
type: { type: "string", description: "Media type machine name" },
|
|
210
240
|
name: { type: "string", description: "Media entity name / label" },
|
|
211
|
-
status: { type: "boolean", default: true },
|
|
241
|
+
status: { type: "boolean", default: false, description: "Published flag. Defaults to false (unpublished). Requires allowPublish when true." },
|
|
212
242
|
fields: { type: "object", description: "Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')" },
|
|
213
243
|
},
|
|
214
244
|
},
|
|
@@ -247,7 +277,7 @@ export const definitions = [
|
|
|
247
277
|
type: "object", required: ["filePath", "bundle", "fieldName"],
|
|
248
278
|
properties: {
|
|
249
279
|
site: { type: "string" },
|
|
250
|
-
filePath: { type: "string", description: "
|
|
280
|
+
filePath: { type: "string", description: "Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)" },
|
|
251
281
|
entityType: { type: "string", default: "media", description: "Drupal entity type (usually 'media' or 'node')" },
|
|
252
282
|
bundle: { type: "string", description: "Bundle machine name, e.g. 'image', 'article'" },
|
|
253
283
|
fieldName: { type: "string", description: "Field machine name, e.g. 'field_media_image', 'field_image'" },
|
|
@@ -256,17 +286,17 @@ export const definitions = [
|
|
|
256
286
|
},
|
|
257
287
|
{
|
|
258
288
|
name: "drupal_upload_file_and_create_media",
|
|
259
|
-
description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow.",
|
|
289
|
+
description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished.",
|
|
260
290
|
inputSchema: {
|
|
261
291
|
type: "object", required: ["filePath", "mediaType", "fieldName"],
|
|
262
292
|
properties: {
|
|
263
293
|
site: { type: "string" },
|
|
264
|
-
filePath: { type: "string", description: "
|
|
294
|
+
filePath: { type: "string", description: "Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)" },
|
|
265
295
|
mediaType: { type: "string", description: "Media type machine name, e.g. 'image'" },
|
|
266
296
|
mediaName: { type: "string", description: "Name for the media entity (defaults to filename)" },
|
|
267
297
|
fieldName: { type: "string", description: "Source field machine name, e.g. 'field_media_image'" },
|
|
268
298
|
altText: { type: "string", description: "Alt text for image media" },
|
|
269
|
-
status: { type: "boolean", default: true },
|
|
299
|
+
status: { type: "boolean", default: false, description: "Published flag. Defaults to false. Requires allowPublish when true." },
|
|
270
300
|
},
|
|
271
301
|
},
|
|
272
302
|
},
|
package/src/tools/moderation.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
import { getSiteConfig } from "../lib/config.js";
|
|
18
18
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertPublishAllowed,
|
|
21
|
+
redactCanonicalEntity,
|
|
22
|
+
} from "../lib/security.js";
|
|
20
23
|
|
|
21
24
|
/** Read a node's moderation_state from a canonical entity, tolerating shapes. */
|
|
22
25
|
function moderationStateOf(entity) {
|
|
@@ -37,8 +40,10 @@ async function setModerationState({ site: siteName, type, id, state }) {
|
|
|
37
40
|
const site = getSiteConfig(siteName);
|
|
38
41
|
const sec = resolveSecurityConfig(site);
|
|
39
42
|
assertWriteAllowed(sec, "update", "node", type);
|
|
43
|
+
const attributes = { moderation_state: state };
|
|
44
|
+
assertPublishAllowed(sec, attributes);
|
|
40
45
|
const backend = await resolveBackend(site);
|
|
41
|
-
const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes
|
|
46
|
+
const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
42
47
|
return redactCanonicalEntity(entity, sec, "node");
|
|
43
48
|
}
|
|
44
49
|
|
package/src/tools/nodes.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import { getSiteConfig } from "../lib/config.js";
|
|
11
11
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
resolveSecurityConfig, redactCanonicalEntity,
|
|
14
|
+
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
15
|
+
} from "../lib/security.js";
|
|
16
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
13
17
|
import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
14
18
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
15
19
|
|
|
@@ -156,6 +160,7 @@ function pageOf({ limit = 20, offset = 0 }) {
|
|
|
156
160
|
async function getNode({ site: siteName, type, id }) {
|
|
157
161
|
const site = getSiteConfig(siteName);
|
|
158
162
|
const sec = resolveSecurityConfig(site);
|
|
163
|
+
assertReadAllowed(sec, "node", type);
|
|
159
164
|
const backend = await resolveBackend(site);
|
|
160
165
|
const entity = await backend.getEntity({ entityType: "node", bundle: type, id });
|
|
161
166
|
return entity ? redactCanonicalEntity(entity, sec, "node") : null;
|
|
@@ -172,6 +177,7 @@ async function getNode({ site: siteName, type, id }) {
|
|
|
172
177
|
async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
|
|
173
178
|
const site = getSiteConfig(siteName);
|
|
174
179
|
const sec = resolveSecurityConfig(site);
|
|
180
|
+
assertReadAllowed(sec, "node", type);
|
|
175
181
|
const backend = await resolveBackend(site);
|
|
176
182
|
const allFilters = [...filters];
|
|
177
183
|
if (status !== undefined) allFilters.push({ field: "status", op: "eq", value: status });
|
|
@@ -195,10 +201,12 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
|
|
|
195
201
|
async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
|
|
196
202
|
const site = getSiteConfig(siteName);
|
|
197
203
|
const sec = resolveSecurityConfig(site);
|
|
204
|
+
const bundle = type || "article";
|
|
205
|
+
assertReadAllowed(sec, "node", bundle);
|
|
198
206
|
const backend = await resolveBackend(site);
|
|
199
207
|
const filters = [{ field: "title", op: "contains", value: query }];
|
|
200
208
|
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
201
|
-
const res = await backend.listEntities({ entityType: "node", bundle
|
|
209
|
+
const res = await backend.listEntities({ entityType: "node", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
|
|
202
210
|
return res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
203
211
|
}
|
|
204
212
|
|
|
@@ -225,6 +233,8 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
|
|
|
225
233
|
*/
|
|
226
234
|
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
227
235
|
const site = getSiteConfig(siteName);
|
|
236
|
+
const sec = resolveSecurityConfig(site);
|
|
237
|
+
assertWriteAllowed(sec, "create", "node", type);
|
|
228
238
|
const attributes = { title, ...fields };
|
|
229
239
|
if (moderationState !== undefined) {
|
|
230
240
|
attributes.moderation_state = moderationState;
|
|
@@ -233,7 +243,7 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
233
243
|
}
|
|
234
244
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
235
245
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
236
|
-
assertPublishAllowed(
|
|
246
|
+
assertPublishAllowed(sec, attributes);
|
|
237
247
|
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
|
|
238
248
|
const backend = await resolveBackend(site);
|
|
239
249
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
@@ -256,6 +266,11 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
256
266
|
* bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
|
|
257
267
|
* types. `moderationState` takes precedence; both are optional on update.
|
|
258
268
|
*
|
|
269
|
+
* Safe default (#131): when the target is a *published* moderated node and the
|
|
270
|
+
* caller did not pass `moderationState` / `moderation_state`, the write is
|
|
271
|
+
* forced to `moderation_state: draft` so it becomes a forward revision rather
|
|
272
|
+
* than mutating the live default revision. Opt out by passing an explicit state.
|
|
273
|
+
*
|
|
259
274
|
* Alias hardening: a partial update that doesn't touch `path` can still lose the
|
|
260
275
|
* node's URL alias when a module (e.g. Pathauto, in automatic mode) regenerates
|
|
261
276
|
* it on save. To preserve the existing alias, when the caller supplies no `path`
|
|
@@ -269,16 +284,22 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
269
284
|
*/
|
|
270
285
|
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
271
286
|
const site = getSiteConfig(siteName);
|
|
272
|
-
const
|
|
287
|
+
const sec = resolveSecurityConfig(site);
|
|
288
|
+
assertWriteAllowed(sec, "update", "node", type);
|
|
289
|
+
const backend = await resolveBackend(site);
|
|
290
|
+
let attributes = { ...fields };
|
|
273
291
|
if (title !== undefined) attributes.title = title;
|
|
274
292
|
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
275
293
|
else if (status !== undefined) attributes.status = status;
|
|
276
294
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
277
295
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
278
|
-
|
|
296
|
+
// #131: published moderated nodes without an explicit state → draft forward revision.
|
|
297
|
+
// Runs before the publish gate and on dryRun so previews match the real write.
|
|
298
|
+
attributes = await applySafeDraftDefault({
|
|
299
|
+
backend, entityType: "node", bundle: type, id, attributes,
|
|
300
|
+
});
|
|
301
|
+
assertPublishAllowed(sec, attributes);
|
|
279
302
|
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
|
|
280
|
-
const backend = await resolveBackend(site);
|
|
281
|
-
const sec = resolveSecurityConfig(site);
|
|
282
303
|
// Alias handling (DEV-116): an explicit `path.alias` is set in place by
|
|
283
304
|
// round-tripping the existing alias's pid (no duplicate); a path-less update
|
|
284
305
|
// re-pins the current alias *with its pid* so the save can't revert/duplicate
|
|
@@ -304,6 +325,8 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
304
325
|
*/
|
|
305
326
|
async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
306
327
|
const site = getSiteConfig(siteName);
|
|
328
|
+
const sec = resolveSecurityConfig(site);
|
|
329
|
+
assertDeleteAllowed(sec, "node", type, id);
|
|
307
330
|
if (dryRun) return { dryRun: true, operation: "delete", entityType: "node", bundle: type, id };
|
|
308
331
|
const backend = await resolveBackend(site);
|
|
309
332
|
await backend.deleteEntity({ entityType: "node", bundle: type, id });
|
|
@@ -379,7 +402,7 @@ export const definitions = [
|
|
|
379
402
|
},
|
|
380
403
|
{
|
|
381
404
|
name: "drupal_update_node",
|
|
382
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`.",
|
|
405
|
+
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`.",
|
|
383
406
|
inputSchema: {
|
|
384
407
|
type: "object", required: ["type", "id"],
|
|
385
408
|
properties: {
|
|
@@ -390,7 +413,7 @@ export const definitions = [
|
|
|
390
413
|
body: { type: "string" },
|
|
391
414
|
summary: { type: "string" },
|
|
392
415
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
393
|
-
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
|
|
416
|
+
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'." },
|
|
394
417
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
|
|
395
418
|
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 }, …] }." },
|
|
396
419
|
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
|
package/src/tools/taxonomy.js
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { getSiteConfig } from "../lib/config.js";
|
|
10
10
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
resolveSecurityConfig, redactCanonicalEntity,
|
|
13
|
+
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
|
|
14
|
+
} from "../lib/security.js";
|
|
12
15
|
|
|
13
16
|
// ---------------------------------------------------------------------------
|
|
14
17
|
// Implementations
|
|
@@ -22,6 +25,7 @@ import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js
|
|
|
22
25
|
*/
|
|
23
26
|
async function listVocabularies({ site: siteName }) {
|
|
24
27
|
const site = getSiteConfig(siteName);
|
|
28
|
+
assertReadAllowed(resolveSecurityConfig(site), "taxonomy_term");
|
|
25
29
|
const backend = await resolveBackend(site);
|
|
26
30
|
return backend.listBundles("taxonomy_term");
|
|
27
31
|
}
|
|
@@ -35,6 +39,7 @@ async function listVocabularies({ site: siteName }) {
|
|
|
35
39
|
async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset = 0 }) {
|
|
36
40
|
const site = getSiteConfig(siteName);
|
|
37
41
|
const sec = resolveSecurityConfig(site);
|
|
42
|
+
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
38
43
|
const backend = await resolveBackend(site);
|
|
39
44
|
const res = await backend.listEntities({
|
|
40
45
|
entityType: "taxonomy_term", bundle: vocabulary,
|
|
@@ -56,6 +61,7 @@ async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset
|
|
|
56
61
|
async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
|
|
57
62
|
const site = getSiteConfig(siteName);
|
|
58
63
|
const sec = resolveSecurityConfig(site);
|
|
64
|
+
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
59
65
|
const backend = await resolveBackend(site);
|
|
60
66
|
const entity = await backend.getEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
|
|
61
67
|
return entity ? redactCanonicalEntity(entity, sec, "taxonomy_term") : null;
|
|
@@ -70,6 +76,7 @@ async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
|
|
|
70
76
|
*/
|
|
71
77
|
async function createTaxonomyTerm({ site: siteName, vocabulary, name, description, weight = 0, parentId }) {
|
|
72
78
|
const site = getSiteConfig(siteName);
|
|
79
|
+
assertWriteAllowed(resolveSecurityConfig(site), "create", "taxonomy_term", vocabulary);
|
|
73
80
|
const backend = await resolveBackend(site);
|
|
74
81
|
const attributes = { name, weight };
|
|
75
82
|
if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
|
|
@@ -88,6 +95,7 @@ async function createTaxonomyTerm({ site: siteName, vocabulary, name, descriptio
|
|
|
88
95
|
*/
|
|
89
96
|
async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight }) {
|
|
90
97
|
const site = getSiteConfig(siteName);
|
|
98
|
+
assertWriteAllowed(resolveSecurityConfig(site), "update", "taxonomy_term", vocabulary);
|
|
91
99
|
const backend = await resolveBackend(site);
|
|
92
100
|
const attributes = {};
|
|
93
101
|
if (name !== undefined) attributes.name = name;
|
|
@@ -105,6 +113,7 @@ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, descri
|
|
|
105
113
|
*/
|
|
106
114
|
async function deleteTaxonomyTerm({ site: siteName, vocabulary, id }) {
|
|
107
115
|
const site = getSiteConfig(siteName);
|
|
116
|
+
assertDeleteAllowed(resolveSecurityConfig(site), "taxonomy_term", vocabulary, id);
|
|
108
117
|
const backend = await resolveBackend(site);
|
|
109
118
|
await backend.deleteEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
|
|
110
119
|
return { success: true, deletedId: id };
|