drupal-mcp-connector 2.1.1 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/drupal-create-node.md +3 -2
- package/.claude/commands/drupal-graphql-introspect.md +2 -2
- package/.claude/commands/drupal-graphql.md +4 -2
- package/.claude/commands/drupal-update-node.md +3 -2
- package/CHANGELOG.md +74 -0
- package/README.md +15 -8
- package/package.json +7 -3
- package/src/lib/security.js +46 -6
- package/src/tools/graphql.js +17 -5
- package/src/tools/nodes.js +39 -12
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Create a new content node. Returns the new node UUID, integer ID, and URL. For content types under an editorial (content_moderation) workflow, set moderationState (e.g. 'draft'/'published') instead of status. Entity-reference fields (taxonomy terms, related content, media) go in `relationships`, not `fields`."
|
|
3
|
-
argument-hint: "<type> <title> [site] [body] [summary] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
3
|
+
argument-hint: "<type> <title> [site] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_create_node
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -17,7 +17,8 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
19
|
- `body` (string): Body field HTML
|
|
20
|
-
- `summary` (string): Body summary /
|
|
20
|
+
- `summary` (string): Body summary/teaser — writes the `summary` property of the body field (core `text_with_summary`). Many headless sites instead use a dedicated summary/deck field for teasers and meta descriptions; on those, set that field in `fields` — a value written here will be stored but may never be rendered.
|
|
21
|
+
- `format` (string): Text format machine name for the body, e.g. 'basic_html'. Defaults to the site config's `defaultTextFormat`, then 'full_html'. Set this when the site's formats do not include full_html, or to avoid writing content into a more permissive format than intended.
|
|
21
22
|
- `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
23
|
- `moderationState` (string): Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status.
|
|
23
24
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by Drupal machine name. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type."
|
|
2
|
+
description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type. Requires security.allowGraphql (off outside the development preset by default)."
|
|
3
3
|
argument-hint: "[site] [typeName]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_graphql_introspect
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_graphql_introspect` MCP tool.
|
|
8
8
|
|
|
9
|
-
Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type.
|
|
9
|
+
Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type. Requires security.allowGraphql (off outside the development preset by default).
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Execute a GraphQL query against a Drupal site. Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which exposes a read-only schema
|
|
2
|
+
description: "Execute a GraphQL query against a Drupal site. Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which exposes a read-only schema. GraphQL is off unless security.allowGraphql is true (development preset only by default) because raw results bypass entity allowlists and field redaction. Mutations also require allowGraphqlMutations. Use drupal_graphql_introspect first to discover available types and fields. Example query: query GetArticle($id: String!) { nodeById(id: $id) { title ... on NodeArticle { body { value } } } } Example mutation (only if your GraphQL Compose schema enables mutations): mutation CreateArticle($title: String!, $body: String!) { createNodeArticle(data: { title: $title, body: { value: $body, format: \"full_html\" } }) { entity { title uuid } errors { message } } }"
|
|
3
3
|
argument-hint: "<query> [site] [variables] [operationName]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_graphql
|
|
5
5
|
---
|
|
@@ -8,7 +8,9 @@ Call the `mcp__drupal__drupal_graphql` MCP tool.
|
|
|
8
8
|
|
|
9
9
|
Execute a GraphQL query against a Drupal site.
|
|
10
10
|
Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which
|
|
11
|
-
exposes a read-only schema
|
|
11
|
+
exposes a read-only schema. GraphQL is off unless security.allowGraphql is true
|
|
12
|
+
(development preset only by default) because raw results bypass entity allowlists
|
|
13
|
+
and field redaction. Mutations also require allowGraphqlMutations.
|
|
12
14
|
Use drupal_graphql_introspect first to discover available types and fields.
|
|
13
15
|
|
|
14
16
|
Example query:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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
|
-
argument-hint: "<type> <id> [site] [title] [body] [summary] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
3
|
+
argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_update_node
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -18,7 +18,8 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
19
|
- `title` (string)
|
|
20
20
|
- `body` (string)
|
|
21
|
-
- `summary` (string)
|
|
21
|
+
- `summary` (string): Body summary/teaser — writes the `summary` property of the body field (core `text_with_summary`). Many headless sites instead use a dedicated summary/deck field for teasers and meta descriptions; on those, set that field in `fields` — a value written here will be stored but may never be rendered.
|
|
22
|
+
- `format` (string): Text format machine name for the body, e.g. 'basic_html'. Defaults to the site config's `defaultTextFormat`, then 'full_html'. Set this when the site's formats do not include full_html, or to avoid writing content into a more permissive format than intended.
|
|
22
23
|
- `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
|
|
23
24
|
- `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
25
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.2.1] - 2026-08-02
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The body text format is no longer hardcoded to `full_html`.** Node writes
|
|
14
|
+
that used the `body` convenience parameter always sent `format: "full_html"`,
|
|
15
|
+
so the connector could not write a body at all on a site whose text formats
|
|
16
|
+
omit it — and on sites that do define it, content was silently written into
|
|
17
|
+
the most permissive core format. Writes now resolve the format explicitly: a
|
|
18
|
+
new per-call `format` argument, then the site config's `defaultTextFormat`,
|
|
19
|
+
then `full_html` as the unchanged last-resort fallback. A text format is
|
|
20
|
+
Drupal's HTML-filtering boundary, so it should be a decision rather than an
|
|
21
|
+
assumption.
|
|
22
|
+
- **A body-only update no longer blanks an existing body summary.** The body
|
|
23
|
+
descriptor set `summary: ""` whenever no summary was supplied, so updating
|
|
24
|
+
just the body erased the summary as a side effect. The property is now sent
|
|
25
|
+
only when the caller supplies it; passing `summary: ""` still clears it
|
|
26
|
+
deliberately.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **`summary` documents what it actually targets.** The parameter writes the
|
|
30
|
+
`summary` property of the core `text_with_summary` body field. Headless sites
|
|
31
|
+
commonly use a dedicated summary/deck field for teasers and meta descriptions
|
|
32
|
+
instead; on those, the value written here is stored but may never be rendered.
|
|
33
|
+
Both node tools now say so, and point at `fields` for the dedicated field.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **`@claude` GitHub Action (inline).** Maintainers can tag `@claude` on an
|
|
37
|
+
issue or PR and Claude acts on the repo and opens a PR. Only
|
|
38
|
+
owner/member/collaborator authors trigger it; bot actors are excluded; the
|
|
39
|
+
job runs on `ubuntu-latest`, stops at opening a PR, and adds no AI
|
|
40
|
+
attribution. Inert until the Claude GitHub App is installed and the
|
|
41
|
+
`CLAUDE_CODE_OAUTH_TOKEN` org secret is made visible to this repo.
|
|
42
|
+
- **CI: No AI attribution gate.** Pull requests fail when commits, the
|
|
43
|
+
PR title, or the PR body credit AI with authorship (shared Wilkes & Liberty
|
|
44
|
+
drop-in). Covers server-side paths that local hooks cannot see.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- **CI: the attribution gate reads its scripts from the base commit.** A
|
|
48
|
+
pull request can no longer supply the code that decides whether it passes.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- **CI: the `@claude` workflow can fetch its OIDC token.** The action exchanges
|
|
52
|
+
a GitHub OIDC token for its app credentials in every auth mode; without
|
|
53
|
+
`id-token: write` the first activation run failed before reaching Claude.
|
|
54
|
+
- **CI: the attribution gate no longer fails on clean commits.** The stripper
|
|
55
|
+
compared each commit message against a copy that had gained a trailing newline,
|
|
56
|
+
so every commit looked modified and the run ended with `strip count > 0 but tip
|
|
57
|
+
unchanged`.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## [2.2.0] - 2026-07-30
|
|
61
|
+
|
|
62
|
+
### Security
|
|
63
|
+
- **Default security preset is `production-strict` (#140).** Omitting `security`
|
|
64
|
+
or passing `{}` no longer opens the site under the `development` preset.
|
|
65
|
+
Local and integration work must set `"preset": "development"` (or another
|
|
66
|
+
explicit preset) deliberately. Migration: add
|
|
67
|
+
`"security": { "preset": "development" }` to any site config that relied on
|
|
68
|
+
the old open default.
|
|
69
|
+
- **GraphQL tools fail closed (#142).** `drupal_graphql` and
|
|
70
|
+
`drupal_graphql_introspect` require `security.allowGraphql` (true only on the
|
|
71
|
+
`development` preset). Mutations still need `allowGraphqlMutations` as well.
|
|
72
|
+
When GraphQL is opted in, results remain raw (no entity allowlist/redaction
|
|
73
|
+
on that path); prefer JSON:API entity tools for policy-bound reads.
|
|
74
|
+
- **Dependency advisories cleared (#128).** Bump
|
|
75
|
+
`@modelcontextprotocol/sdk` to `^1.30.0` and pin overrides for
|
|
76
|
+
`@hono/node-server` `^2.0.5` and `postcss` `^8.5.18` so `npm audit` reports
|
|
77
|
+
zero vulnerabilities.
|
|
78
|
+
|
|
79
|
+
### Documentation
|
|
80
|
+
- README, security guide, threat model, architecture, tools reference, and
|
|
81
|
+
SECURITY.md updated for the secure default, GraphQL gate, and residual-risk
|
|
82
|
+
table.
|
|
83
|
+
|
|
10
84
|
## [2.1.1] - 2026-07-31
|
|
11
85
|
|
|
12
86
|
### Documentation
|
package/README.md
CHANGED
|
@@ -44,6 +44,13 @@ Each site declares which backend(s) it exposes via the `api` key:
|
|
|
44
44
|
`{ id, entityType, bundle, title, status, langcode, created, changed, url, fields, relationships, _backend }`, so tool output is identical regardless of protocol.
|
|
45
45
|
- **Capability-aware.** Each backend advertises what it supports (read, write, delete, server-side filter/sort, revisions). GraphQL via GraphQL Compose is **read-only** (no mutations) and has no server-side field filter, so filters are applied client-side over a bounded fetch and flagged `approximate`/`truncated`. Write tools against a read-only backend return a clear capability error rather than failing silently.
|
|
46
46
|
- **Writes go through JSON:API.** Use a JSON:API-enabled site as the write plane; keep GraphQL as a read plane where that suits your architecture.
|
|
47
|
+
- **`defaultTextFormat` sets the body text format** used by the `body` convenience
|
|
48
|
+
parameter on node writes, e.g. `{ "baseUrl": "…", "defaultTextFormat": "basic_html" }`.
|
|
49
|
+
Individual calls can override it with `format`. Without either, the connector falls
|
|
50
|
+
back to `full_html`, which many governed sites deliberately do not define — and which
|
|
51
|
+
is the most permissive core format, so setting this per site is the safer posture. A
|
|
52
|
+
text format is Drupal's HTML-filtering boundary, so it is worth choosing deliberately
|
|
53
|
+
rather than inheriting.
|
|
47
54
|
|
|
48
55
|
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.
|
|
49
56
|
|
|
@@ -127,9 +134,9 @@ prompts above.
|
|
|
127
134
|
### Security Model
|
|
128
135
|
|
|
129
136
|
Defense-in-depth presets, enforced connector-side and complemented by Drupal-side
|
|
130
|
-
governance (MCP Sentinel). **
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
governance (MCP Sentinel). **Default when `security` is omitted:**
|
|
138
|
+
`production-strict` (read-only, sensitive types denied). Local development must
|
|
139
|
+
set `"preset": "development"` explicitly.
|
|
133
140
|
|
|
134
141
|
```json
|
|
135
142
|
"security": { "preset": "auditor" }
|
|
@@ -137,20 +144,20 @@ for change in issue #140).
|
|
|
137
144
|
|
|
138
145
|
| Preset | What it does |
|
|
139
146
|
|--------|-------------|
|
|
140
|
-
| `development` | Everything allowed — local development only |
|
|
147
|
+
| `development` | Everything allowed, including GraphQL — local development only |
|
|
141
148
|
| `content-editor` | Create/edit content + structural entities; no deletes; no publishing; secrets/governance/account types denied |
|
|
142
149
|
| `config-editor` | content-editor + site-building config read + governed config write (developer tier) |
|
|
143
150
|
| `auditor` | Read-only; secrets/governance/account types denied; user PII redacted when user is allowed |
|
|
144
|
-
| `production-strict` | Read-only; same sensitive denylist as auditor; broad PII field redaction |
|
|
145
|
-
| `write-plane` | Create/update content set for agents; no deletes; no GraphQL
|
|
151
|
+
| `production-strict` | Read-only; same sensitive denylist as auditor; broad PII field redaction (**default**) |
|
|
152
|
+
| `write-plane` | Create/update content set for agents; no deletes; no GraphQL; publish off by default |
|
|
146
153
|
|
|
147
|
-
Additional connector-side gates (2.1+):
|
|
154
|
+
Additional connector-side gates (2.1+ / 2.2+):
|
|
148
155
|
|
|
149
156
|
- **Entity allowlists** apply to specialized tools (`drupal_*_node`, media, taxonomy), not only `drupal_entity_*`.
|
|
150
157
|
- **Publish gate:** `status: true` and `moderation_state: "published"` require `allowPublish`. Media create defaults **unpublished**. Published moderated node updates without a moderation state default to **draft** (forward revision).
|
|
151
158
|
- **Uploads** only from `MCP_UPLOAD_ROOT` (or the process cwd); sensitive paths (`.env*`, `.ssh`, connector `config.json`) are refused.
|
|
152
159
|
- **HTTPS:** non-loopback binds require `MCP_AUTH_TOKEN` (or `MCP_ALLOW_UNAUTHENTICATED=1` behind a trusted proxy); non-loopback TLS defaults to 120 req/min rate limiting.
|
|
153
|
-
- **GraphQL
|
|
160
|
+
- **GraphQL is off by default.** `drupal_graphql` / introspect require `security.allowGraphql` (true only on the `development` preset). Raw GraphQL results still bypass entity allowlists and field redaction — prefer JSON:API entity tools when connector policy must hold. Mutations also need `allowGraphqlMutations`.
|
|
154
161
|
|
|
155
162
|
Full detail: **[docs/security.md](docs/security.md)** and **[docs/security-hardening.md](docs/security-hardening.md)**.
|
|
156
163
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -58,16 +58,20 @@
|
|
|
58
58
|
"syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f ✓\"; done"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
62
62
|
"graphql": "^17.0.0",
|
|
63
63
|
"node-fetch": "^3.3.2",
|
|
64
64
|
"ssh2": "^1.16.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"eslint": "^10.4.1",
|
|
68
|
-
"eslint-plugin-security": "^4.0.0",
|
|
69
68
|
"eslint-plugin-n": "^18.1.0",
|
|
69
|
+
"eslint-plugin-security": "^4.0.0",
|
|
70
70
|
"globals": "^17.6.0",
|
|
71
71
|
"vitest": "^4.1.8"
|
|
72
|
+
},
|
|
73
|
+
"overrides": {
|
|
74
|
+
"@hono/node-server": "^2.0.5",
|
|
75
|
+
"postcss": "^8.5.18"
|
|
72
76
|
}
|
|
73
77
|
}
|
package/src/lib/security.js
CHANGED
|
@@ -12,13 +12,14 @@ import { parse } from "graphql";
|
|
|
12
12
|
*
|
|
13
13
|
* ─── Quick presets ────────────────────────────────────────────────────────
|
|
14
14
|
*
|
|
15
|
-
* "preset": "development" Everything allowed.
|
|
15
|
+
* "preset": "development" Everything allowed. Opt-in only — set explicitly.
|
|
16
16
|
* "preset": "content-editor" Create/edit content (nodes, media, terms, paragraphs, 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.
|
|
20
|
-
* "preset": "auditor" Read-only.
|
|
21
|
-
* "preset": "production-strict" Read-only.
|
|
20
|
+
* "preset": "auditor" Read-only. Sensitive entity types denied. User fields redacted.
|
|
21
|
+
* "preset": "production-strict" Read-only. Sensitive types denied. Redacts PII. **Default** when
|
|
22
|
+
* security is omitted or has no preset (#140).
|
|
22
23
|
* "preset": "write-plane" Governed writes (no delete/mutations) on the content set
|
|
23
24
|
* (node, term, media + structural content entities).
|
|
24
25
|
*
|
|
@@ -31,7 +32,8 @@ import { parse } from "graphql";
|
|
|
31
32
|
*
|
|
32
33
|
* readOnly true → reject all create/update/delete/graphql-mutation calls
|
|
33
34
|
* allowDestructive false → reject all delete operations
|
|
34
|
-
* allowPublish false → reject a write carrying status:true
|
|
35
|
+
* allowPublish false → reject a write carrying status:true / moderation_state:published
|
|
36
|
+
* allowGraphql false → reject drupal_graphql + introspect (raw GraphQL is policy-free, #142)
|
|
35
37
|
* allowGraphqlMutations false → reject drupal_graphql when mutation is detected
|
|
36
38
|
* allowConfigRead false → reject drupal_config_get / drupal_config_list
|
|
37
39
|
* allowConfigWrite false → reject drupal_config_set
|
|
@@ -118,6 +120,7 @@ const PRESETS = {
|
|
|
118
120
|
readOnly: false,
|
|
119
121
|
allowDestructive: true,
|
|
120
122
|
allowPublish: true, // mirrors allowDestructive: everything allowed
|
|
123
|
+
allowGraphql: true, // raw GraphQL allowed only in open mode (#142)
|
|
121
124
|
allowGraphqlMutations: true,
|
|
122
125
|
allowConfigRead: true,
|
|
123
126
|
allowConfigWrite: true,
|
|
@@ -130,6 +133,7 @@ const PRESETS = {
|
|
|
130
133
|
"content-editor": {
|
|
131
134
|
readOnly: false,
|
|
132
135
|
allowDestructive: false, // no deletes
|
|
136
|
+
allowGraphql: false, // freeform GraphQL bypasses entity denylists (#142)
|
|
133
137
|
allowGraphqlMutations: false,
|
|
134
138
|
allowConfigRead: true, // config read-only
|
|
135
139
|
allowConfigWrite: false,
|
|
@@ -151,6 +155,7 @@ const PRESETS = {
|
|
|
151
155
|
// The Drupal-side governance layer remains authoritative; this is defence in depth.
|
|
152
156
|
readOnly: false,
|
|
153
157
|
allowDestructive: false, // no deletes
|
|
158
|
+
allowGraphql: false,
|
|
154
159
|
allowGraphqlMutations: false,
|
|
155
160
|
allowConfigRead: true,
|
|
156
161
|
allowConfigWrite: true, // governed config writes via drupal_config_set
|
|
@@ -171,6 +176,7 @@ const PRESETS = {
|
|
|
171
176
|
auditor: {
|
|
172
177
|
readOnly: true,
|
|
173
178
|
allowDestructive: false,
|
|
179
|
+
allowGraphql: false,
|
|
174
180
|
allowGraphqlMutations: false,
|
|
175
181
|
allowConfigRead: true, // read-only inspection of config
|
|
176
182
|
allowConfigWrite: false,
|
|
@@ -189,6 +195,7 @@ const PRESETS = {
|
|
|
189
195
|
"production-strict": {
|
|
190
196
|
readOnly: true,
|
|
191
197
|
allowDestructive: false,
|
|
198
|
+
allowGraphql: false,
|
|
192
199
|
allowGraphqlMutations: false,
|
|
193
200
|
allowConfigRead: false, // nothing implicit; opt in per site
|
|
194
201
|
allowConfigWrite: false,
|
|
@@ -203,6 +210,7 @@ const PRESETS = {
|
|
|
203
210
|
// Drupal-side governance layer remains authoritative; this is defence in depth.
|
|
204
211
|
readOnly: false,
|
|
205
212
|
allowDestructive: false, // no deletes
|
|
213
|
+
allowGraphql: false, // JSON:API write plane; GraphQL is policy-free (#142)
|
|
206
214
|
allowGraphqlMutations: false, // writes go through the JSON:API plane
|
|
207
215
|
allowConfigRead: true, // config read-only
|
|
208
216
|
allowConfigWrite: false,
|
|
@@ -225,15 +233,22 @@ const PRESETS = {
|
|
|
225
233
|
* @param {object} site Site config (reads site.security).
|
|
226
234
|
* @returns {object} Effective security config used by the assert/redact helpers.
|
|
227
235
|
*/
|
|
236
|
+
/** Default when `security.preset` is omitted — least privilege, not open mode (#140). */
|
|
237
|
+
export const DEFAULT_SECURITY_PRESET = "production-strict";
|
|
238
|
+
|
|
228
239
|
export function resolveSecurityConfig(site) {
|
|
229
240
|
const raw = site.security ?? {};
|
|
230
|
-
|
|
241
|
+
// Explicit preset wins; bare {} / missing security → production-strict (#140).
|
|
242
|
+
// Open mode requires `preset: "development"` (or another named preset).
|
|
243
|
+
const presetName = raw.preset ?? DEFAULT_SECURITY_PRESET;
|
|
244
|
+
const preset = PRESETS[presetName] ?? PRESETS[DEFAULT_SECURITY_PRESET];
|
|
231
245
|
|
|
232
246
|
// Merge: explicit keys in site.security override the preset
|
|
233
247
|
return {
|
|
234
248
|
readOnly: raw.readOnly ?? preset.readOnly,
|
|
235
249
|
allowDestructive: raw.allowDestructive ?? preset.allowDestructive,
|
|
236
250
|
allowPublish: raw.allowPublish ?? preset.allowPublish ?? false,
|
|
251
|
+
allowGraphql: raw.allowGraphql ?? preset.allowGraphql ?? false,
|
|
237
252
|
allowGraphqlMutations: raw.allowGraphqlMutations ?? preset.allowGraphqlMutations,
|
|
238
253
|
allowConfigRead: raw.allowConfigRead ?? preset.allowConfigRead ?? false,
|
|
239
254
|
allowConfigWrite: raw.allowConfigWrite ?? preset.allowConfigWrite ?? false,
|
|
@@ -450,7 +465,30 @@ function graphqlHasMutation(query) {
|
|
|
450
465
|
* @returns {void} No-op for read-only (query) documents.
|
|
451
466
|
* @throws {SecurityError} if the document is a mutation and writes/mutations are disabled.
|
|
452
467
|
*/
|
|
468
|
+
/**
|
|
469
|
+
* Gate GraphQL queries and schema introspection. Freeform GraphQL returns raw
|
|
470
|
+
* data that does not pass through entity allowlists or field redaction (#142),
|
|
471
|
+
* so it is off by default outside the development preset. Opt in with
|
|
472
|
+
* `security.allowGraphql: true`.
|
|
473
|
+
*
|
|
474
|
+
* @param {object} secConfig Resolved security config.
|
|
475
|
+
* @param {string} [operationLabel] Label for the error message.
|
|
476
|
+
* @returns {void}
|
|
477
|
+
* @throws {SecurityError} if GraphQL is disabled for this site.
|
|
478
|
+
*/
|
|
479
|
+
export function assertGraphqlAllowed(secConfig, operationLabel = "graphql") {
|
|
480
|
+
if (secConfig.allowGraphql) return;
|
|
481
|
+
throw new SecurityError(
|
|
482
|
+
"GraphQL tools are disabled for this site (allowGraphql: false). " +
|
|
483
|
+
"Raw GraphQL responses bypass connector entity allowlists and field redaction. " +
|
|
484
|
+
`Blocked: ${operationLabel}. ` +
|
|
485
|
+
"Set security.allowGraphql = true to enable (prefer development preset or an explicit opt-in)."
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
453
489
|
export function assertGraphqlMutationAllowed(secConfig, query) {
|
|
490
|
+
// Queries still require allowGraphql (#142); mutations require both flags.
|
|
491
|
+
assertGraphqlAllowed(secConfig, "graphql mutation");
|
|
454
492
|
const isMutation = graphqlHasMutation(query);
|
|
455
493
|
if (!isMutation) return;
|
|
456
494
|
|
|
@@ -681,9 +719,11 @@ export function getSecuritySummary(site) {
|
|
|
681
719
|
const cfg = resolveSecurityConfig(site);
|
|
682
720
|
return {
|
|
683
721
|
site: site._name,
|
|
684
|
-
preset: site.security?.preset ??
|
|
722
|
+
preset: site.security?.preset ?? `${DEFAULT_SECURITY_PRESET} (default)`,
|
|
685
723
|
readOnly: cfg.readOnly,
|
|
686
724
|
allowDestructive: cfg.allowDestructive,
|
|
725
|
+
allowPublish: cfg.allowPublish,
|
|
726
|
+
allowGraphql: cfg.allowGraphql,
|
|
687
727
|
allowGraphqlMutations: cfg.allowGraphqlMutations,
|
|
688
728
|
allowConfigRead: cfg.allowConfigRead,
|
|
689
729
|
allowConfigWrite: cfg.allowConfigWrite,
|
package/src/tools/graphql.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Raw GraphQL execution and schema introspection against a Drupal site. Require
|
|
5
5
|
* the GraphQL Compose module (drupal.org/project/graphql_compose), which
|
|
6
|
-
* exposes a read-only schema (no mutations)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* exposes a read-only schema (no mutations).
|
|
7
|
+
*
|
|
8
|
+
* Policy: responses are raw GraphQL data — they do **not** pass connector entity
|
|
9
|
+
* allowlists or field redaction (#142). Tools are gated by `allowGraphql`
|
|
10
|
+
* (false outside the development preset unless opted in). Mutations also require
|
|
11
|
+
* `allowGraphqlMutations` (enforced in the handler and by index.js middleware).
|
|
9
12
|
*
|
|
10
13
|
* Per-site config: set "graphqlEndpoint" to override "/graphql".
|
|
11
14
|
* Auth reuses the same credentials as the JSON:API tools.
|
|
@@ -13,6 +16,9 @@
|
|
|
13
16
|
|
|
14
17
|
import { getSiteConfig } from "../lib/config.js";
|
|
15
18
|
import { drupalGraphqlFetch } from "../lib/drupal-fetch.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveSecurityConfig, assertGraphqlAllowed, assertGraphqlMutationAllowed,
|
|
21
|
+
} from "../lib/security.js";
|
|
16
22
|
|
|
17
23
|
// ---------------------------------------------------------------------------
|
|
18
24
|
// Implementations
|
|
@@ -29,6 +35,9 @@ import { drupalGraphqlFetch } from "../lib/drupal-fetch.js";
|
|
|
29
35
|
*/
|
|
30
36
|
async function runGraphql({ site: siteName, query, variables = {}, operationName }) {
|
|
31
37
|
const site = getSiteConfig(siteName);
|
|
38
|
+
const sec = resolveSecurityConfig(site);
|
|
39
|
+
assertGraphqlAllowed(sec, "drupal_graphql");
|
|
40
|
+
assertGraphqlMutationAllowed(sec, query);
|
|
32
41
|
const json = await drupalGraphqlFetch(site, { query, variables, operationName });
|
|
33
42
|
|
|
34
43
|
if (json.errors?.length) {
|
|
@@ -54,6 +63,7 @@ async function runGraphql({ site: siteName, query, variables = {}, operationName
|
|
|
54
63
|
*/
|
|
55
64
|
async function introspectGraphql({ site: siteName, typeName }) {
|
|
56
65
|
const site = getSiteConfig(siteName);
|
|
66
|
+
assertGraphqlAllowed(resolveSecurityConfig(site), "drupal_graphql_introspect");
|
|
57
67
|
|
|
58
68
|
// If a specific type is requested, get detailed field info for it.
|
|
59
69
|
if (typeName) {
|
|
@@ -128,7 +138,9 @@ export const definitions = [
|
|
|
128
138
|
name: "drupal_graphql",
|
|
129
139
|
description: `Execute a GraphQL query against a Drupal site.
|
|
130
140
|
Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which
|
|
131
|
-
exposes a read-only schema
|
|
141
|
+
exposes a read-only schema. GraphQL is off unless security.allowGraphql is true
|
|
142
|
+
(development preset only by default) because raw results bypass entity allowlists
|
|
143
|
+
and field redaction. Mutations also require allowGraphqlMutations.
|
|
132
144
|
Use drupal_graphql_introspect first to discover available types and fields.
|
|
133
145
|
|
|
134
146
|
Example query:
|
|
@@ -158,7 +170,7 @@ Example mutation (only if your GraphQL Compose schema enables mutations):
|
|
|
158
170
|
},
|
|
159
171
|
{
|
|
160
172
|
name: "drupal_graphql_introspect",
|
|
161
|
-
description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type.",
|
|
173
|
+
description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type. Requires security.allowGraphql (off outside the development preset by default).",
|
|
162
174
|
inputSchema: {
|
|
163
175
|
type: "object",
|
|
164
176
|
properties: {
|
package/src/tools/nodes.js
CHANGED
|
@@ -127,18 +127,43 @@ async function createRenameRedirect(backend, sec, redirect) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Fallback text format when neither the call nor the site config names one.
|
|
132
|
+
*
|
|
133
|
+
* Retained for backward compatibility only. A site whose text formats do not
|
|
134
|
+
* include `full_html` — or whose agent account may not use it — must set
|
|
135
|
+
* `defaultTextFormat` in its site config or pass `format` per call.
|
|
136
|
+
*/
|
|
137
|
+
const FALLBACK_TEXT_FORMAT = "full_html";
|
|
138
|
+
|
|
130
139
|
/**
|
|
131
140
|
* Build a Drupal body field descriptor from plain HTML + optional summary.
|
|
132
141
|
*
|
|
142
|
+
* The text format is a security boundary in Drupal (it decides which HTML
|
|
143
|
+
* survives filtering), so it is resolved explicitly rather than assumed:
|
|
144
|
+
* an explicit `format` argument wins, then the site's `defaultTextFormat`,
|
|
145
|
+
* then the historical fallback.
|
|
146
|
+
*
|
|
147
|
+
* `summary` is only included when the caller supplied it. Sending an empty
|
|
148
|
+
* string on every write would blank an existing body summary whenever a
|
|
149
|
+
* caller updated the body alone.
|
|
150
|
+
*
|
|
133
151
|
* @param {string} [body] - Body HTML; when undefined the field is omitted.
|
|
134
|
-
* @param {string} [summary] - Teaser/summary text.
|
|
135
|
-
* @
|
|
152
|
+
* @param {string} [summary] - Teaser/summary text; omitted when undefined.
|
|
153
|
+
* @param {string} [format] - Text format machine name.
|
|
154
|
+
* @param {object} [site] - Site config, read for `defaultTextFormat`.
|
|
155
|
+
* @returns {{value: string, format: string, summary?: string}|undefined}
|
|
136
156
|
* A body attribute object, or undefined when no body was supplied (so callers
|
|
137
157
|
* can skip the field on update rather than blanking it).
|
|
138
158
|
*/
|
|
139
|
-
function buildBodyAttribute(body, summary) {
|
|
159
|
+
function buildBodyAttribute(body, summary, format, site) {
|
|
140
160
|
if (body === undefined) return undefined;
|
|
141
|
-
|
|
161
|
+
const attr = {
|
|
162
|
+
value: body,
|
|
163
|
+
format: format ?? site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT,
|
|
164
|
+
};
|
|
165
|
+
if (summary !== undefined) attr.summary = summary;
|
|
166
|
+
return attr;
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
/**
|
|
@@ -228,10 +253,10 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
|
|
|
228
253
|
* `relationships` (JSON:API shape), not `fields`; Drupal rejects reference fields
|
|
229
254
|
* sent as attributes (#115).
|
|
230
255
|
*
|
|
231
|
-
* @param {object} args - { site?, type, title, body?, summary?, status?, moderationState?, fields?, relationships? }.
|
|
256
|
+
* @param {object} args - { site?, type, title, body?, summary?, format?, status?, moderationState?, fields?, relationships? }.
|
|
232
257
|
* @returns {Promise<object>} The created node descriptor from the backend.
|
|
233
258
|
*/
|
|
234
|
-
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
259
|
+
async function createNode({ site: siteName, type, title, body, summary, format, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
235
260
|
const site = getSiteConfig(siteName);
|
|
236
261
|
const sec = resolveSecurityConfig(site);
|
|
237
262
|
assertWriteAllowed(sec, "create", "node", type);
|
|
@@ -241,7 +266,7 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
241
266
|
} else {
|
|
242
267
|
attributes.status = status === undefined ? false : status;
|
|
243
268
|
}
|
|
244
|
-
const bodyAttr = buildBodyAttribute(body, summary);
|
|
269
|
+
const bodyAttr = buildBodyAttribute(body, summary, format, site);
|
|
245
270
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
246
271
|
assertPublishAllowed(sec, attributes);
|
|
247
272
|
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
|
|
@@ -279,10 +304,10 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
279
304
|
*
|
|
280
305
|
* Entity-reference fields go in `relationships` (JSON:API shape), not `fields` (#115).
|
|
281
306
|
*
|
|
282
|
-
* @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields?, relationships? }.
|
|
307
|
+
* @param {object} args - { site?, type, id, title?, body?, summary?, format?, status?, moderationState?, fields?, relationships? }.
|
|
283
308
|
* @returns {Promise<object>} The updated node descriptor.
|
|
284
309
|
*/
|
|
285
|
-
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
310
|
+
async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
286
311
|
const site = getSiteConfig(siteName);
|
|
287
312
|
const sec = resolveSecurityConfig(site);
|
|
288
313
|
assertWriteAllowed(sec, "update", "node", type);
|
|
@@ -291,7 +316,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
291
316
|
if (title !== undefined) attributes.title = title;
|
|
292
317
|
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
293
318
|
else if (status !== undefined) attributes.status = status;
|
|
294
|
-
const bodyAttr = buildBodyAttribute(body, summary);
|
|
319
|
+
const bodyAttr = buildBodyAttribute(body, summary, format, site);
|
|
295
320
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
296
321
|
// #131: published moderated nodes without an explicit state → draft forward revision.
|
|
297
322
|
// Runs before the publish gate and on dryRun so previews match the real write.
|
|
@@ -390,7 +415,8 @@ export const definitions = [
|
|
|
390
415
|
type: { type: "string", description: "Content type machine name" },
|
|
391
416
|
title: { type: "string" },
|
|
392
417
|
body: { type: "string", description: "Body field HTML" },
|
|
393
|
-
summary: { type: "string", description: "Body summary /
|
|
418
|
+
summary: { type: "string", description: "Body summary/teaser — writes the `summary` property of the body field (core `text_with_summary`). Many headless sites instead use a dedicated summary/deck field for teasers and meta descriptions; on those, set that field in `fields` — a value written here will be stored but may never be rendered." },
|
|
419
|
+
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. Defaults to the site config's `defaultTextFormat`, then 'full_html'. Set this when the site's formats do not include full_html, or to avoid writing content into a more permissive format than intended." },
|
|
394
420
|
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." },
|
|
395
421
|
moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
|
|
396
422
|
fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal machine name. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
|
|
@@ -411,7 +437,8 @@ export const definitions = [
|
|
|
411
437
|
id: { type: "string", description: "Node UUID" },
|
|
412
438
|
title: { type: "string" },
|
|
413
439
|
body: { type: "string" },
|
|
414
|
-
summary: { type: "string" },
|
|
440
|
+
summary: { type: "string", description: "Body summary/teaser — writes the `summary` property of the body field (core `text_with_summary`). Many headless sites instead use a dedicated summary/deck field for teasers and meta descriptions; on those, set that field in `fields` — a value written here will be stored but may never be rendered." },
|
|
441
|
+
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. Defaults to the site config's `defaultTextFormat`, then 'full_html'. Set this when the site's formats do not include full_html, or to avoid writing content into a more permissive format than intended." },
|
|
415
442
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
416
443
|
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'." },
|
|
417
444
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
|