drupal-mcp-connector 1.7.0 → 1.8.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-create-node.md +6 -4
- package/.claude/commands/drupal-describe-fields.md +3 -2
- package/.claude/commands/drupal-entity-create.md +2 -1
- package/.claude/commands/drupal-entity-update.md +2 -1
- package/.claude/commands/drupal-report-seo-audit.md +2 -2
- package/.claude/commands/drupal-update-node.md +6 -4
- package/CHANGELOG.md +104 -0
- package/package.json +1 -1
- package/src/index.js +9 -8
- package/src/lib/backends/index.js +87 -45
- package/src/lib/backends/jsonapi.js +9 -4
- package/src/lib/entity-response.js +46 -0
- package/src/lib/metatag-audit.js +117 -0
- package/src/lib/security.js +39 -0
- package/src/tools/bulk.js +3 -1
- package/src/tools/config.js +4 -3
- package/src/tools/entities.js +10 -5
- package/src/tools/fields.js +20 -7
- package/src/tools/nodes.js +29 -16
- package/src/tools/reports.js +67 -13
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
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."
|
|
3
|
-
argument-hint: "<type> <title> [site] [body] [summary] [status] [moderationState] [fields] [dryRun]"
|
|
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]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_create_node
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_create_node` MCP tool.
|
|
8
8
|
|
|
9
|
-
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.
|
|
9
|
+
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`.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -20,7 +20,9 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
20
20
|
- `summary` (string): Body summary / teaser
|
|
21
21
|
- `status` (boolean (true/false)): Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically.
|
|
22
22
|
- `moderationState` (string): Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status.
|
|
23
|
-
- `fields` (object (pass as JSON)):
|
|
23
|
+
- `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`.
|
|
24
|
+
- `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value: { field_resource_type: { data: { type: 'taxonomy_term--resource_type', id: '<uuid>' } } }. Multi-value: { field_tags: { data: [{ type: 'taxonomy_term--tags', id: '<uuid>' }] } }.
|
|
24
25
|
- `dryRun` (boolean (true/false)): Validate and return a preview of the write without committing.
|
|
26
|
+
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
25
27
|
|
|
26
28
|
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,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Introspect the fields of a Drupal entity type + bundle: returns a per-field list of { name, type, kind, cardinality?, approximate }. Read-only. Built on schema SAMPLING (an existing entity), so results are approximate — only populated fields are visible and required/cardinality/allowedValues are inferred from value shape. Authoritative field metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names."
|
|
3
|
-
argument-hint: "<site>
|
|
3
|
+
argument-hint: "<site> [type] [entityType] [bundle]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_describe_fields
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -12,9 +12,10 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
12
12
|
|
|
13
13
|
**Required:**
|
|
14
14
|
- `site` (string): Configured site name.
|
|
15
|
-
- `type` (string): Entity type machine name, e.g. 'node', 'taxonomy_term', 'user', 'media'.
|
|
16
15
|
|
|
17
16
|
**Optional:**
|
|
17
|
+
- `type` (string): Entity type machine name, e.g. 'node', 'taxonomy_term', 'user', 'media'. Alias: `entityType` (as used by the sibling tools).
|
|
18
|
+
- `entityType` (string): Alias for `type` — accepted for parity with get_entity_schema / entity_create / entity_update / resolve_reference.
|
|
18
19
|
- `bundle` (string): Bundle machine name, e.g. 'article'. Defaults to the entity type for single-bundle types (e.g. 'user').
|
|
19
20
|
|
|
20
21
|
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,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Create an entity of any Drupal entity type and bundle. Use drupal_get_entity_schema first to know what fields are available. All operations checked against security config."
|
|
3
|
-
argument-hint: "<entityType> <bundle> [site] [attributes] [relationships] [dryRun]"
|
|
3
|
+
argument-hint: "<entityType> <bundle> [site] [attributes] [relationships] [dryRun] [returning]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_entity_create
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -19,5 +19,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
19
19
|
- `attributes` (object (pass as JSON)): Field values keyed by Drupal machine name
|
|
20
20
|
- `relationships` (object (pass as JSON)): Relationship data keyed by field name
|
|
21
21
|
- `dryRun` (boolean (true/false)): Validate and return a preview of the create without committing.
|
|
22
|
+
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
22
23
|
|
|
23
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change."
|
|
3
|
-
argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun]"
|
|
3
|
+
argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_entity_update
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -20,5 +20,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
20
20
|
- `attributes` (object (pass as JSON))
|
|
21
21
|
- `relationships` (object (pass as JSON))
|
|
22
22
|
- `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
|
|
23
|
+
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
23
24
|
|
|
24
25
|
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: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category."
|
|
2
|
+
description: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category. Meta descriptions use the rendered Metatag output via GraphQL when available (reported as `metaSource`); when no description source is readable it reports the meta check as unavailable rather than a false zero."
|
|
3
3
|
argument-hint: "[site] [type] [sampleSize]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_report_seo_audit
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_report_seo_audit` MCP tool.
|
|
8
8
|
|
|
9
|
-
SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category.
|
|
9
|
+
SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category. Meta descriptions use the rendered Metatag output via GraphQL when available (reported as `metaSource`); when no description source is readable it reports the meta check as unavailable rather than a false zero.
|
|
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."
|
|
3
|
-
argument-hint: "<type> <id> [site] [title] [body] [summary] [status] [moderationState] [fields] [dryRun]"
|
|
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`."
|
|
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.
|
|
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`.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -21,7 +21,9 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
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
23
|
- `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status.
|
|
24
|
-
- `fields` (object (pass as JSON))
|
|
24
|
+
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
|
|
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 }, …] }.
|
|
25
26
|
- `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
|
|
27
|
+
- `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
|
|
26
28
|
|
|
27
29
|
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.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.8.0] - 2026-07-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`returning: "minimal"` on write tools (#113).** `drupal_entity_create/update` and
|
|
14
|
+
`drupal_create_node/update_node` returned the complete re-read entity on every write —
|
|
15
|
+
several thousand tokens for a node with a body (included twice, `value` + `processed`),
|
|
16
|
+
most of it unrelated to the change, which made bulk content work exhaust an agent's
|
|
17
|
+
context window. A new `returning` parameter (`"full"` default, preserving today's
|
|
18
|
+
contract; `"minimal"` opt-in) returns just identity + state (id, type, bundle, title,
|
|
19
|
+
status, changed, url), recommended for bulk writes. (`drupal_bulk_create/update` already
|
|
20
|
+
return only per-item id + status.)
|
|
21
|
+
- **`security.allowPublish` policy knob (#114).** A local, fail-fast publish gate,
|
|
22
|
+
symmetric with `allowDestructive`: defaults `false` in every preset except
|
|
23
|
+
`development`, and an operator opts in per site. `assertPublishAllowed` rejects a
|
|
24
|
+
write carrying `status: true` before the round-trip when publishing is not permitted.
|
|
25
|
+
`drupal_mcp_whoami` now **derives** `capabilities.publish` from it (`allowPublish &&
|
|
26
|
+
write`) instead of returning a hardcoded `false`. The remote Drupal's permissions
|
|
27
|
+
(and any server-side governance) remain the real authority — this is defence in depth.
|
|
28
|
+
- **Launcher: auditor secret sourcing.** `bin/drupal-mcp-launch.sh` now optionally
|
|
29
|
+
sources the read-only **config-auditor** Keychain secrets (`drupal-mcp-auditor-secret`
|
|
30
|
+
→ `MCP_AGENT_AUDITOR_SECRET`, `drupal-mcp-auditor-secret-stg` →
|
|
31
|
+
`MCP_AGENT_AUDITOR_SECRET_STG`) for the `prod-audit` and `staging-audit` connector
|
|
32
|
+
sites. Both exports are guarded — silent no-ops until the auditor consumers are
|
|
33
|
+
provisioned — matching the existing per-environment secret-sourcing pattern.
|
|
34
|
+
- **Launcher: content-auditor and break-glass admin secret sourcing.**
|
|
35
|
+
`bin/drupal-mcp-launch.sh` now also sources the read-only **content-auditor**
|
|
36
|
+
secrets (`drupal-mcp-content-auditor-secret` → `MCP_AGENT_CONTENT_AUDITOR_SECRET`,
|
|
37
|
+
`drupal-mcp-content-auditor-secret-stg` → `MCP_AGENT_CONTENT_AUDITOR_SECRET_STG`) and
|
|
38
|
+
the on-demand **break-glass admin** secret (`drupal-mcp-admin-secret` →
|
|
39
|
+
`MCP_AGENT_ADMIN_SECRET`). All are guarded no-ops until the matching Keychain items
|
|
40
|
+
exist; the admin item is deliberately absent by default so the `prod-admin` site stays
|
|
41
|
+
inert until you opt in for a session and remove it afterward.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
- **`drupal-content-audit` prompt is now content-type-agnostic (#122).** The prompt
|
|
45
|
+
hardcoded `article` for its SEO and accessibility steps, so on a site without that
|
|
46
|
+
type — or one whose model was consolidated — those steps scanned zero nodes and the
|
|
47
|
+
audit reported no findings, indistinguishable from a genuinely clean scan. It now
|
|
48
|
+
derives the types to audit from `drupal_report_content_summary`'s `byContentType`
|
|
49
|
+
inventory, iterates the per-type checks across every type that has nodes, records
|
|
50
|
+
zero-node types as empty rather than clean, prefers `drupal_report_seo_meta_coverage`
|
|
51
|
+
(which reads the site's actual meta field) for the SEO step, and states which types
|
|
52
|
+
were scanned so an empty or unexpected model can't be mistaken for a passing audit.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
- **`drupal_describe_fields` entity-type parameter name mismatch (#116).** The tool took
|
|
56
|
+
the entity type as `type` while its siblings (`get_entity_schema`, `entity_create`,
|
|
57
|
+
`entity_update`, `resolve_reference`) take `entityType`; passing the sibling name
|
|
58
|
+
slipped through as `undefined` and surfaced a misleading "Entity type 'undefined' is
|
|
59
|
+
not in the allowedEntityTypes list" access error. It now accepts `entityType` as an
|
|
60
|
+
alias for `type`, and errors clearly (naming both accepted parameters) when neither is
|
|
61
|
+
given instead of reporting a phantom access-control failure.
|
|
62
|
+
- **Backend resolution misdiagnosed auth failures as unreachable (#119).** The probe
|
|
63
|
+
swallowed every error and reported "none of the configured api backends are usable —
|
|
64
|
+
check the api setting and that the endpoint is reachable," sending operators to chase
|
|
65
|
+
network/DNS when the real problem was an expired/invalid OAuth token. Resolution now
|
|
66
|
+
captures each protocol's underlying error, classifies auth failures (401,
|
|
67
|
+
invalid_client/grant, unauthorized) distinctly, includes the underlying detail in
|
|
68
|
+
every message, and on an auth failure clears the cached token so the next call
|
|
69
|
+
re-attempts the client-credentials grant instead of latching "unusable."
|
|
70
|
+
- **`drupal_create_node` / `drupal_update_node` couldn't set entity-reference fields (#115).**
|
|
71
|
+
Everything in `fields` was sent as JSON:API attributes, so any create/update that set a
|
|
72
|
+
reference field (taxonomy, related content, media) failed with a 422 — the node tools
|
|
73
|
+
could only produce untagged, unclassified content. Both tools now take a `relationships`
|
|
74
|
+
parameter (JSON:API shape, same as `drupal_entity_create`) that is passed through to the
|
|
75
|
+
backend, and `fields`/`relationships` are documented so reference fields land in the right
|
|
76
|
+
place.
|
|
77
|
+
- **Publish state silently dropped on writes (#111).** A write carrying `status: true`
|
|
78
|
+
at a tier that cannot publish was silently discarded (200, entity unchanged, no
|
|
79
|
+
diagnostic). Two causes, both fixed: the new `assertPublishAllowed` gate now rejects
|
|
80
|
+
such a write up front with a clear error, and the JSON:API moderated-status retry no
|
|
81
|
+
longer matches a generic `field (status)` **permission** denial — that is a real
|
|
82
|
+
refusal and now surfaces, instead of being retried away as a moderation quirk. Only
|
|
83
|
+
the unambiguous "published field of moderated entities" error still triggers the
|
|
84
|
+
status-drop retry.
|
|
85
|
+
- **`dryRun` echoed input instead of validating (#112).** `dryRun` returned the request
|
|
86
|
+
parameters without applying tier policy, so it previewed writes that could not happen.
|
|
87
|
+
It now runs the same write **and publish** checks as the real call, so a dry run fails
|
|
88
|
+
exactly where the write would.
|
|
89
|
+
- **`whoami` hardcoded `capabilities.publish: false` (#114).** A site-specific claim in
|
|
90
|
+
a site-agnostic tool, neither derived nor enforced. Now derived from `allowPublish`
|
|
91
|
+
(see Added).
|
|
92
|
+
- **Bulk writes bypassed the publish gate.** `drupal_bulk_create`/`drupal_bulk_update` now
|
|
93
|
+
apply `assertPublishAllowed` per item (a publish-bearing item fails on its own, without
|
|
94
|
+
aborting the batch), so the new `allowPublish` policy can't be sidestepped in bulk.
|
|
95
|
+
- **SEO audit: false "0 missing meta descriptions" on Metatag sites (#120).**
|
|
96
|
+
`drupal_report_seo_audit` counted the JSON:API `metatag` field as a present
|
|
97
|
+
description, but that field is an unresolved placeholder over JSON:API, so every
|
|
98
|
+
node looked covered and the audit reported zero gaps while pages shipped without a
|
|
99
|
+
description. The meta check now resolves the **rendered** description from GraphQL
|
|
100
|
+
Compose's normalized `metatag` field (`route(path:)`, no introspection required —
|
|
101
|
+
reflecting defaults *and* per-node overrides), falls back to a plain
|
|
102
|
+
`field_meta_description`/`metaDescription` field on non-Metatag sites, and when
|
|
103
|
+
neither is readable reports the check as `unavailable` rather than a false zero. The
|
|
104
|
+
result now carries a `metaSource` of `graphql` | `jsonapi` | `unavailable`.
|
|
105
|
+
- **Docs: stale counts corrected.** The getting-started first-run banner and the
|
|
106
|
+
architecture/whitepaper figures still read `v1.3.0 / 93 tools / 21 modules / 4 prompts`;
|
|
107
|
+
updated to the current build — **119 tools across 26 modules, 3 resources, 124 prompts**
|
|
108
|
+
— so onboarding output matches what a new user actually sees.
|
|
109
|
+
|
|
110
|
+
### Changed
|
|
111
|
+
- **Docs: per-client cross-link.** `getting-started.md` §6 now points to `mcp-clients.md`
|
|
112
|
+
for copy-paste config per client (Claude Code/Desktop, Grok Build, OpenAI Codex, Cursor).
|
|
113
|
+
|
|
10
114
|
## [1.7.0] - 2026-07-01
|
|
11
115
|
|
|
12
116
|
### Added
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -226,14 +226,15 @@ function getPromptMessages(name, args) {
|
|
|
226
226
|
const prompts = {
|
|
227
227
|
"drupal-content-audit": [
|
|
228
228
|
{ role: "user", content: { type: "text", text:
|
|
229
|
-
`Please run a comprehensive content audit ${site}.
|
|
230
|
-
"1. Call drupal_report_content_summary to
|
|
231
|
-
"2.
|
|
232
|
-
"3.
|
|
233
|
-
"4.
|
|
234
|
-
"5.
|
|
235
|
-
"6.
|
|
236
|
-
"
|
|
229
|
+
`Please run a comprehensive content audit ${site}. Do not assume any particular content type exists — every site has a different model, so discover it first and audit the types this site actually has.\n` +
|
|
230
|
+
"1. Call drupal_report_content_summary for the full inventory. Its byContentType list is the set of content types to audit — derive the types from it; never assume a fixed type such as \"article\".\n" +
|
|
231
|
+
"2. For each content type that has nodes, call drupal_report_stale_content (days: 180).\n" +
|
|
232
|
+
"3. For each content type that has nodes, call drupal_report_field_completeness.\n" +
|
|
233
|
+
"4. For each content type with published nodes, check SEO: prefer drupal_report_seo_meta_coverage (it reads the site's actual meta field rather than assuming one) and use drupal_report_seo_audit for title-length and thin-content checks.\n" +
|
|
234
|
+
"5. For each content type with published nodes, call drupal_report_accessibility_audit.\n" +
|
|
235
|
+
"6. For any content type reporting zero nodes, skip its per-type scans and record it as empty — an empty type is not a clean one.\n" +
|
|
236
|
+
"7. Synthesize findings into: (a) immediate actions, (b) medium-term improvements, (c) process recommendations.\n" +
|
|
237
|
+
"Present results as a structured report with counts, severity, and specific node links where possible. State which content types were scanned so an empty or unexpected model cannot be mistaken for a clean audit."
|
|
237
238
|
}},
|
|
238
239
|
],
|
|
239
240
|
"drupal-create-article": [
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { drupalFetch } from "../drupal-fetch.js";
|
|
12
12
|
import { drupalGraphqlFetch } from "../drupal-fetch.js";
|
|
13
|
+
import { clearToken } from "../oauth.js";
|
|
13
14
|
import { JsonApiBackend } from "./jsonapi.js";
|
|
14
15
|
import { GraphqlBackend } from "./graphql.js";
|
|
15
16
|
import { BackendResolutionError } from "./errors.js";
|
|
@@ -31,6 +32,25 @@ export function _clearBackendCache() {
|
|
|
31
32
|
cache.clear();
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Classify a probe failure as an authentication problem (expired/invalid OAuth
|
|
37
|
+
* token or bad client credentials) rather than an unreachable endpoint. This is
|
|
38
|
+
* the distinction #119 needs: an auth failure and a network failure demand
|
|
39
|
+
* different fixes, and the generic "endpoint not reachable" message sent
|
|
40
|
+
* operators to the wrong place.
|
|
41
|
+
* @param {unknown} err
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function isAuthError(err) {
|
|
45
|
+
const msg = String(err?.message || "");
|
|
46
|
+
return (
|
|
47
|
+
/\b401\b/.test(msg) ||
|
|
48
|
+
(/\b403\b/.test(msg) && /token|oauth|credential|scope/i.test(msg)) ||
|
|
49
|
+
/invalid_client|invalid_grant|unauthorized_client|invalid_token/i.test(msg) ||
|
|
50
|
+
/\bunauthorized\b/i.test(msg)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
/**
|
|
35
55
|
* Resolve (and cache) the backend adapter for a site.
|
|
36
56
|
* @param {object} site Site config; must include `_name` and may include `api`.
|
|
@@ -41,25 +61,10 @@ export async function resolveBackend(site) {
|
|
|
41
61
|
if (cache.has(site._name)) return cache.get(site._name);
|
|
42
62
|
|
|
43
63
|
const order = normalizeApiOrder(site.api);
|
|
44
|
-
|
|
64
|
+
const probeOrder = order ?? [...REGISTRY.keys()];
|
|
65
|
+
const { backend, failures } = await resolveFromOrder(site, probeOrder);
|
|
45
66
|
|
|
46
|
-
if (order)
|
|
47
|
-
backend = await firstUsable(site, order);
|
|
48
|
-
if (!backend) {
|
|
49
|
-
throw new BackendResolutionError(
|
|
50
|
-
`Site "${site._name}": none of the configured api backends [${order.join(", ")}] are usable. ` +
|
|
51
|
-
"Check the \"api\" setting and that the endpoint is reachable."
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
} else {
|
|
55
|
-
backend = await probe(site);
|
|
56
|
-
if (!backend) {
|
|
57
|
-
throw new BackendResolutionError(
|
|
58
|
-
`Site "${site._name}": could not auto-detect a usable API. ` +
|
|
59
|
-
"Set \"api\" in config (e.g. \"graphql\" or \"jsonapi\")."
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
67
|
+
if (!backend) throw buildResolutionError(site, order, failures);
|
|
63
68
|
|
|
64
69
|
cache.set(site._name, backend);
|
|
65
70
|
return backend;
|
|
@@ -78,51 +83,88 @@ function normalizeApiOrder(api) {
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
|
-
*
|
|
86
|
+
* Probe each protocol in order, returning the first usable backend plus the
|
|
87
|
+
* per-protocol failures (captured, not swallowed) for diagnostics.
|
|
82
88
|
* @param {object} site Site config.
|
|
83
89
|
* @param {string[]} order Protocol names in preference order.
|
|
84
|
-
* @returns {Promise
|
|
90
|
+
* @returns {Promise<{backend: ?import("./backend-interface.js").Backend, failures: {name: string, error: Error}[]}>}
|
|
85
91
|
*/
|
|
86
|
-
async function
|
|
92
|
+
async function resolveFromOrder(site, order) {
|
|
93
|
+
const failures = [];
|
|
87
94
|
for (const name of order) {
|
|
88
95
|
const Cls = REGISTRY.get(name);
|
|
89
|
-
if (!Cls)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* Auto-detect a backend by probing each registered protocol in order.
|
|
97
|
-
* @param {object} site Site config.
|
|
98
|
-
* @returns {Promise<?import("./backend-interface.js").Backend>} Instance or null.
|
|
99
|
-
*/
|
|
100
|
-
async function probe(site) {
|
|
101
|
-
for (const [name, Cls] of REGISTRY) {
|
|
102
|
-
if (await isReachable(name, site)) return new Cls(site);
|
|
96
|
+
if (!Cls) {
|
|
97
|
+
failures.push({ name, error: new Error(`no adapter registered for "${name}"`) });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const { ok, error } = await probeProtocol(name, site);
|
|
101
|
+
if (ok) return { backend: new Cls(site), failures };
|
|
102
|
+
failures.push({ name, error });
|
|
103
103
|
}
|
|
104
|
-
return null;
|
|
104
|
+
return { backend: null, failures };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Probe whether a given protocol responds for a site.
|
|
109
|
-
*
|
|
108
|
+
* Probe whether a given protocol responds for a site. Unlike a boolean probe,
|
|
109
|
+
* this captures the underlying error so resolution can tell an auth failure from
|
|
110
|
+
* an unreachable endpoint (#119).
|
|
110
111
|
* @param {string} name Protocol name ("jsonapi" | "graphql").
|
|
111
112
|
* @param {object} site Site config.
|
|
112
|
-
* @returns {Promise<boolean>}
|
|
113
|
+
* @returns {Promise<{ok: boolean, error: ?Error}>}
|
|
113
114
|
*/
|
|
114
|
-
async function
|
|
115
|
+
async function probeProtocol(name, site) {
|
|
115
116
|
try {
|
|
116
117
|
if (name === "jsonapi") {
|
|
117
118
|
await drupalFetch(site, "/jsonapi");
|
|
118
|
-
return true;
|
|
119
|
+
return { ok: true, error: null };
|
|
119
120
|
}
|
|
120
121
|
if (name === "graphql") {
|
|
121
122
|
const json = await drupalGraphqlFetch(site, { query: "{ __typename }" });
|
|
122
|
-
|
|
123
|
+
if (json && !json.errors) return { ok: true, error: null };
|
|
124
|
+
return { ok: false, error: new Error(json?.errors?.[0]?.message || "GraphQL probe returned errors") };
|
|
123
125
|
}
|
|
124
|
-
return false;
|
|
125
|
-
} catch {
|
|
126
|
-
return false;
|
|
126
|
+
return { ok: false, error: new Error(`unknown backend "${name}"`) };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { ok: false, error };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build a diagnostic BackendResolutionError from the captured probe failures.
|
|
134
|
+
* An auth failure gets its own message (and clears the cached token so the next
|
|
135
|
+
* call re-attempts the grant instead of latching "unusable"); otherwise the
|
|
136
|
+
* message points at reachability/config — but always includes the underlying
|
|
137
|
+
* error so the operator is not sent to the wrong place (#119).
|
|
138
|
+
* @param {object} site Site config.
|
|
139
|
+
* @param {?string[]} order The configured api order (null when auto-detecting).
|
|
140
|
+
* @param {{name: string, error: Error}[]} failures Per-protocol failures.
|
|
141
|
+
* @returns {BackendResolutionError}
|
|
142
|
+
*/
|
|
143
|
+
function buildResolutionError(site, order, failures) {
|
|
144
|
+
const detail = failures.map((f) => `${f.name}: ${f.error?.message || "unknown error"}`).join(" | ");
|
|
145
|
+
const authFailure = failures.some((f) => isAuthError(f.error));
|
|
146
|
+
|
|
147
|
+
if (authFailure) {
|
|
148
|
+
// Recovery: drop any cached token so the next resolveBackend re-grants
|
|
149
|
+
// (client_credentials refresh) rather than replaying a stale/invalid token.
|
|
150
|
+
if (site.oauth) clearToken(site);
|
|
151
|
+
return new BackendResolutionError(
|
|
152
|
+
`Site "${site._name}": authentication failed against the configured backend(s) — ` +
|
|
153
|
+
"this is an auth problem (expired/invalid OAuth token or client credentials), not reachability. " +
|
|
154
|
+
`Underlying: ${detail}. ` +
|
|
155
|
+
"Check the OAuth client_id/secret and scopes. The cached token has been cleared, so the next call will re-attempt the grant."
|
|
156
|
+
);
|
|
127
157
|
}
|
|
158
|
+
|
|
159
|
+
if (order) {
|
|
160
|
+
return new BackendResolutionError(
|
|
161
|
+
`Site "${site._name}": none of the configured api backends [${order.join(", ")}] are usable. ` +
|
|
162
|
+
`Check the "api" setting and that the endpoint is reachable. Underlying: ${detail}.`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return new BackendResolutionError(
|
|
167
|
+
`Site "${site._name}": could not auto-detect a usable API. ` +
|
|
168
|
+
`Set "api" in config (e.g. "graphql" or "jsonapi"). Underlying: ${detail}.`
|
|
169
|
+
);
|
|
128
170
|
}
|
|
@@ -32,15 +32,20 @@ const COUNT_MAX_RECORDS = 1000;
|
|
|
32
32
|
* Detect the JSON:API error Drupal returns when a write attempts to set the
|
|
33
33
|
* `status` (published) field on a content_moderation-governed entity. Such
|
|
34
34
|
* entities own their published state via `moderation_state`, so a direct
|
|
35
|
-
* `status` write is refused with
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* `status` write is refused with "Cannot edit the published field of moderated
|
|
36
|
+
* entities." Used to decide whether to retry the write without `status`.
|
|
37
|
+
*
|
|
38
|
+
* Matched narrowly, on that moderation-specific phrase only. A generic
|
|
39
|
+
* field-access denial ("The current user is not allowed to … the field
|
|
40
|
+
* (status)") is a *permission* refusal, not a moderation quirk — matching it
|
|
41
|
+
* here would silently drop a caller's status change and return success (#111).
|
|
42
|
+
* That case must surface, so it is deliberately excluded.
|
|
38
43
|
* @param {unknown} err
|
|
39
44
|
* @returns {boolean}
|
|
40
45
|
*/
|
|
41
46
|
export function isModeratedStatusError(err) {
|
|
42
47
|
const msg = String(err?.message || "");
|
|
43
|
-
return /published field of moderated/i.test(msg)
|
|
48
|
+
return /published field of moderated/i.test(msg);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
// Canonical filter op -> JSON:API condition operator.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape a write tool's response.
|
|
3
|
+
*
|
|
4
|
+
* Write tools return the full re-read entity by default, which for a node with a
|
|
5
|
+
* populated body is several thousand tokens per call — most of it unrelated to
|
|
6
|
+
* the change, and with `body.value` and `body.processed` both included in full
|
|
7
|
+
* (#113). The primary consumer is an agent with a bounded context window, so a
|
|
8
|
+
* bulk operation (e.g. tagging many nodes) can exhaust the window on echoed
|
|
9
|
+
* bodies. `returning: "minimal"` opts into an identity + state summary that is
|
|
10
|
+
* enough to confirm the write.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Base fields promoted onto a canonical entity that a caller needs to verify a
|
|
14
|
+
// write. Non-base fields (body, arbitrary attributes, relationships) are omitted
|
|
15
|
+
// in minimal mode. Internal keys prefixed with `_` (e.g. `_redirect`) are
|
|
16
|
+
// preserved separately so tool-specific metadata is not lost.
|
|
17
|
+
const MINIMAL_KEYS = ["id", "entityType", "bundle", "title", "status", "moderation_state", "langcode", "changed", "url"];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object|null} entity Canonical entity (or a write result wrapping one).
|
|
21
|
+
* @param {"full"|"minimal"} [returning] Response verbosity. Defaults to "full".
|
|
22
|
+
* @returns {object|null} The entity, or a compact identity+state summary.
|
|
23
|
+
*/
|
|
24
|
+
export function shapeWriteResponse(entity, returning = "full") {
|
|
25
|
+
if (!entity || returning !== "minimal") return entity;
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const key of MINIMAL_KEYS) {
|
|
28
|
+
if (entity[key] !== undefined && entity[key] !== null) out[key] = entity[key];
|
|
29
|
+
}
|
|
30
|
+
// Preserve tool-specific metadata keys (e.g. `_redirect` from a node rename).
|
|
31
|
+
for (const key of Object.keys(entity)) {
|
|
32
|
+
if (key.startsWith("_")) out[key] = entity[key];
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** JSON Schema fragment for the shared `returning` parameter. */
|
|
38
|
+
export const RETURNING_SCHEMA = {
|
|
39
|
+
type: "string",
|
|
40
|
+
enum: ["full", "minimal"],
|
|
41
|
+
default: "full",
|
|
42
|
+
description:
|
|
43
|
+
"Response verbosity. \"full\" (default) returns the complete saved entity; " +
|
|
44
|
+
"\"minimal\" returns just identity + state (id, type, bundle, title, status, changed, url) — " +
|
|
45
|
+
"much smaller, recommended for bulk writes where the echoed body would dominate the response.",
|
|
46
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendered meta-description resolution for the SEO audit.
|
|
3
|
+
*
|
|
4
|
+
* A Drupal site using the Metatag module computes a node's final tags at render
|
|
5
|
+
* time from bundle *defaults* (often token fallbacks like `[node:summary]`) plus
|
|
6
|
+
* any per-node override. That computed value is NOT resolved over JSON:API — the
|
|
7
|
+
* `metatag` field there is a structural placeholder with empty attributes — so a
|
|
8
|
+
* JSON:API-only audit cannot see whether a description is actually emitted, and
|
|
9
|
+
* treating the placeholder as "present" silently reports 0 missing on every node
|
|
10
|
+
* (see issue #120).
|
|
11
|
+
*
|
|
12
|
+
* The only source that reflects what the frontend renders is GraphQL Compose's
|
|
13
|
+
* normalized `metatag` field (exposed by graphql_compose_metatags). This module
|
|
14
|
+
* fetches it via `route(path:)` — the same entry point the frontend uses — with
|
|
15
|
+
* a query that needs no schema introspection (which many sites disable).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { drupalGraphqlFetch } from "./drupal-fetch.js";
|
|
19
|
+
|
|
20
|
+
// Node paths are batched into aliased `route()` selections per request. Kept
|
|
21
|
+
// modest so a single document stays small and one bad path can't sink a large
|
|
22
|
+
// batch (each alias resolves independently).
|
|
23
|
+
const CHUNK = 25;
|
|
24
|
+
|
|
25
|
+
// `metatag` lives on NodeInterface, so one selection covers every node bundle
|
|
26
|
+
// without knowing per-bundle GraphQL type names (unavailable when introspection
|
|
27
|
+
// is disabled). `route` resolves to a union; the fragment matches the internal
|
|
28
|
+
// (entity-backed) case and is simply skipped for redirects/external routes.
|
|
29
|
+
const ROUTE_META_FRAGMENT = `fragment MetaOnRoute on RouteInternal {
|
|
30
|
+
entity {
|
|
31
|
+
... on NodeInterface {
|
|
32
|
+
metatag {
|
|
33
|
+
__typename
|
|
34
|
+
... on MetaTagValue { attributes { name content } }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pull the rendered meta description out of a GraphQL `metatag` array.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} metatag The node's normalized `metatag` field value.
|
|
44
|
+
* @returns {string} The trimmed description content, or "" when absent/empty.
|
|
45
|
+
*/
|
|
46
|
+
export function metaDescriptionFromMetatag(metatag) {
|
|
47
|
+
if (!Array.isArray(metatag)) return "";
|
|
48
|
+
for (const tag of metatag) {
|
|
49
|
+
if (
|
|
50
|
+
tag &&
|
|
51
|
+
tag.__typename === "MetaTagValue" &&
|
|
52
|
+
tag.attributes &&
|
|
53
|
+
tag.attributes.name === "description"
|
|
54
|
+
) {
|
|
55
|
+
return typeof tag.attributes.content === "string" ? tag.attributes.content.trim() : "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve rendered meta descriptions for a set of published nodes via GraphQL.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} site Resolved site config (for drupalGraphqlFetch).
|
|
65
|
+
* @param {Array<{id: string, url?: ?string}>} entities Nodes to resolve; each
|
|
66
|
+
* must carry its path alias in `url` to be resolvable.
|
|
67
|
+
* @returns {Promise<{source: "graphql"|"unavailable", reason?: string,
|
|
68
|
+
* byId: Map<string, {description: string}>}>}
|
|
69
|
+
* - source "graphql": `byId` maps entity id → { description } for every node
|
|
70
|
+
* whose route resolved to an entity. Nodes with no resolvable route are
|
|
71
|
+
* omitted (unknown), never assumed missing.
|
|
72
|
+
* - source "unavailable": GraphQL, `route`, or the `metatag` field is not
|
|
73
|
+
* reachable. The caller MUST NOT treat this as "0 missing".
|
|
74
|
+
*/
|
|
75
|
+
export async function fetchRenderedMetaDescriptions(site, entities) {
|
|
76
|
+
const byId = new Map();
|
|
77
|
+
const withPath = entities
|
|
78
|
+
.map((e) => ({ id: e.id, path: typeof e.url === "string" ? e.url : null }))
|
|
79
|
+
.filter((e) => e.path && e.path.startsWith("/"));
|
|
80
|
+
|
|
81
|
+
if (withPath.length === 0) {
|
|
82
|
+
return { source: "unavailable", reason: "no node path aliases available to resolve", byId };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < withPath.length; i += CHUNK) {
|
|
86
|
+
const chunk = withPath.slice(i, i + CHUNK);
|
|
87
|
+
const varDecls = chunk.map((_, j) => `$p${j}: String!`).join(", ");
|
|
88
|
+
const selections = chunk.map((_, j) => ` n${j}: route(path: $p${j}) { ...MetaOnRoute }`).join("\n");
|
|
89
|
+
const query = `query MetaAudit(${varDecls}) {\n${selections}\n}\n\n${ROUTE_META_FRAGMENT}`;
|
|
90
|
+
const variables = Object.fromEntries(chunk.map((e, j) => [`p${j}`, e.path]));
|
|
91
|
+
|
|
92
|
+
let json;
|
|
93
|
+
try {
|
|
94
|
+
json = await drupalGraphqlFetch(site, { query, variables });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Endpoint unreachable / not configured / auth failure.
|
|
97
|
+
return { source: "unavailable", reason: `GraphQL request failed: ${err.message}`, byId };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Array.isArray(json.errors) && json.errors.length > 0) {
|
|
101
|
+
// A schema-level error (no `route`, no `metatag` field, graphql_compose_metatags
|
|
102
|
+
// absent) means we cannot determine descriptions — fail closed to
|
|
103
|
+
// "unavailable" rather than reporting every node as missing.
|
|
104
|
+
return { source: "unavailable", reason: json.errors[0]?.message ?? "GraphQL error", byId };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const data = json.data || {};
|
|
108
|
+
chunk.forEach((e, j) => {
|
|
109
|
+
const route = data[`n${j}`];
|
|
110
|
+
const entity = route && route.entity;
|
|
111
|
+
if (!entity) return; // route did not resolve to a node; leave unknown
|
|
112
|
+
byId.set(e.id, { description: metaDescriptionFromMetatag(entity.metatag) });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { source: "graphql", byId };
|
|
117
|
+
}
|
package/src/lib/security.js
CHANGED
|
@@ -31,6 +31,7 @@ import { parse } from "graphql";
|
|
|
31
31
|
*
|
|
32
32
|
* readOnly true → reject all create/update/delete/graphql-mutation calls
|
|
33
33
|
* allowDestructive false → reject all delete operations
|
|
34
|
+
* allowPublish false → reject a write carrying status:true (publishing)
|
|
34
35
|
* allowGraphqlMutations false → reject drupal_graphql when mutation is detected
|
|
35
36
|
* allowConfigRead false → reject drupal_config_get / drupal_config_list
|
|
36
37
|
* allowConfigWrite false → reject drupal_config_set
|
|
@@ -116,6 +117,7 @@ const PRESETS = {
|
|
|
116
117
|
development: {
|
|
117
118
|
readOnly: false,
|
|
118
119
|
allowDestructive: true,
|
|
120
|
+
allowPublish: true, // mirrors allowDestructive: everything allowed
|
|
119
121
|
allowGraphqlMutations: true,
|
|
120
122
|
allowConfigRead: true,
|
|
121
123
|
allowConfigWrite: true,
|
|
@@ -230,6 +232,7 @@ export function resolveSecurityConfig(site) {
|
|
|
230
232
|
return {
|
|
231
233
|
readOnly: raw.readOnly ?? preset.readOnly,
|
|
232
234
|
allowDestructive: raw.allowDestructive ?? preset.allowDestructive,
|
|
235
|
+
allowPublish: raw.allowPublish ?? preset.allowPublish ?? false,
|
|
233
236
|
allowGraphqlMutations: raw.allowGraphqlMutations ?? preset.allowGraphqlMutations,
|
|
234
237
|
allowConfigRead: raw.allowConfigRead ?? preset.allowConfigRead ?? false,
|
|
235
238
|
allowConfigWrite: raw.allowConfigWrite ?? preset.allowConfigWrite ?? false,
|
|
@@ -375,6 +378,42 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
|
|
|
375
378
|
}
|
|
376
379
|
}
|
|
377
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Whether a set of write attributes carries a publish action. Deliberately
|
|
383
|
+
* limited to the unambiguous, entity-agnostic signal `status === true`; a site's
|
|
384
|
+
* moderation-workflow state names are not knowable from a site-agnostic
|
|
385
|
+
* connector, so publishing via `moderation_state` stays gated server-side.
|
|
386
|
+
* @param {object} [attributes] Attribute map for the write.
|
|
387
|
+
* @returns {boolean}
|
|
388
|
+
*/
|
|
389
|
+
export function isPublishBearing(attributes = {}) {
|
|
390
|
+
return attributes?.status === true;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Local, fail-fast publish gate, symmetric with assertDestructiveAllowed. When
|
|
395
|
+
* the connector is not permitted to publish (allowPublish false — the default in
|
|
396
|
+
* every preset except `development`), a write carrying `status: true` is refused
|
|
397
|
+
* before the round-trip, rather than being silently dropped by a moderated-bundle
|
|
398
|
+
* retry or a server-side gate (see #111/#114). Client-side convenience only — the
|
|
399
|
+
* remote Drupal's own permissions remain the real authority.
|
|
400
|
+
* @param {object} secConfig Resolved security config.
|
|
401
|
+
* @param {object} [attributes] Attribute map for the write.
|
|
402
|
+
* @returns {void}
|
|
403
|
+
* @throws {SecurityError} if a publish-bearing write is attempted while allowPublish is false.
|
|
404
|
+
*/
|
|
405
|
+
export function assertPublishAllowed(secConfig, attributes = {}) {
|
|
406
|
+
if (secConfig.allowPublish) return;
|
|
407
|
+
if (isPublishBearing(attributes)) {
|
|
408
|
+
throw new SecurityError(
|
|
409
|
+
"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; otherwise publish " +
|
|
412
|
+
"via an operator/server-gated path (e.g. a moderation transition)."
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
378
417
|
/**
|
|
379
418
|
* Detect whether a GraphQL document contains a mutation operation.
|
|
380
419
|
* Uses a real parser (robust against multi-operation docs, comments, and
|
package/src/tools/bulk.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { getSiteConfig } from "../lib/config.js";
|
|
15
15
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
16
|
-
import { resolveSecurityConfig, assertWriteAllowed } from "../lib/security.js";
|
|
16
|
+
import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Normalize an unknown thrown value into a human-readable message.
|
|
@@ -46,6 +46,7 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
46
46
|
for (const [index, rawItem] of items.entries()) {
|
|
47
47
|
const item = rawItem || {};
|
|
48
48
|
try {
|
|
49
|
+
assertPublishAllowed(sec, item.attributes ?? {});
|
|
49
50
|
const entity = await backend.createEntity({
|
|
50
51
|
entityType, bundle,
|
|
51
52
|
attributes: item.attributes ?? {},
|
|
@@ -84,6 +85,7 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
84
85
|
const item = rawItem || {};
|
|
85
86
|
try {
|
|
86
87
|
if (!item.id) throw new Error("Missing 'id' for update item");
|
|
88
|
+
assertPublishAllowed(sec, item.attributes ?? {});
|
|
87
89
|
const entity = await backend.updateEntity({
|
|
88
90
|
entityType, bundle, id: item.id,
|
|
89
91
|
attributes: item.attributes ?? {},
|
package/src/tools/config.js
CHANGED
|
@@ -130,9 +130,10 @@ async function whoami({ site: siteName }) {
|
|
|
130
130
|
delete: sec.allowDestructive && canWrite,
|
|
131
131
|
configRead: sec.allowConfigRead && canConfig,
|
|
132
132
|
configWrite: sec.allowConfigWrite && !sec.readOnly && canConfig,
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
133
|
+
// Local publish policy (allowPublish), symmetric with delete/config caps.
|
|
134
|
+
// Defaults false in every preset except `development`; the remote Drupal's
|
|
135
|
+
// own permissions (and any server-side governance) remain authoritative.
|
|
136
|
+
publish: sec.allowPublish && canWrite,
|
|
136
137
|
},
|
|
137
138
|
};
|
|
138
139
|
}
|
package/src/tools/entities.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
|
+
import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
13
14
|
import {
|
|
14
|
-
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
|
|
15
|
+
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
15
16
|
redactCanonicalEntity, getSecuritySummary,
|
|
16
17
|
} from "../lib/security.js";
|
|
17
18
|
|
|
@@ -56,13 +57,14 @@ async function getEntity({ site: siteName, entityType, bundle, id, include = []
|
|
|
56
57
|
* @returns {Promise<object>} The created entity descriptor.
|
|
57
58
|
* @throws {SecurityError} If creating the type/bundle is not permitted.
|
|
58
59
|
*/
|
|
59
|
-
async function createEntity({ site: siteName, entityType, bundle, attributes = {}, relationships = {}, dryRun = false }) {
|
|
60
|
+
async function createEntity({ site: siteName, entityType, bundle, attributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
60
61
|
const site = getSiteConfig(siteName);
|
|
61
62
|
const sec = resolveSecurityConfig(site);
|
|
62
63
|
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
64
|
+
assertPublishAllowed(sec, attributes);
|
|
63
65
|
if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships };
|
|
64
66
|
const backend = await resolveBackend(site);
|
|
65
|
-
return backend.createEntity({ entityType, bundle, attributes, relationships });
|
|
67
|
+
return shapeWriteResponse(await backend.createEntity({ entityType, bundle, attributes, relationships }), returning);
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
/**
|
|
@@ -72,13 +74,14 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
72
74
|
* @returns {Promise<object>} The updated entity descriptor.
|
|
73
75
|
* @throws {SecurityError} If updating the type/bundle is not permitted.
|
|
74
76
|
*/
|
|
75
|
-
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {}, dryRun = false }) {
|
|
77
|
+
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
76
78
|
const site = getSiteConfig(siteName);
|
|
77
79
|
const sec = resolveSecurityConfig(site);
|
|
78
80
|
assertWriteAllowed(sec, "update", entityType, bundle);
|
|
81
|
+
assertPublishAllowed(sec, attributes);
|
|
79
82
|
if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes, relationships };
|
|
80
83
|
const backend = await resolveBackend(site);
|
|
81
|
-
return backend.updateEntity({ entityType, bundle, id, attributes, relationships });
|
|
84
|
+
return shapeWriteResponse(await backend.updateEntity({ entityType, bundle, id, attributes, relationships }), returning);
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
@@ -213,6 +216,7 @@ export const definitions = [
|
|
|
213
216
|
attributes: { type: "object", description: "Field values keyed by Drupal machine name" },
|
|
214
217
|
relationships: { type: "object", description: "Relationship data keyed by field name" },
|
|
215
218
|
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the create without committing." },
|
|
219
|
+
returning: RETURNING_SCHEMA,
|
|
216
220
|
},
|
|
217
221
|
},
|
|
218
222
|
},
|
|
@@ -229,6 +233,7 @@ export const definitions = [
|
|
|
229
233
|
attributes: { type: "object" },
|
|
230
234
|
relationships: { type: "object" },
|
|
231
235
|
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
|
|
236
|
+
returning: RETURNING_SCHEMA,
|
|
232
237
|
},
|
|
233
238
|
},
|
|
234
239
|
},
|
package/src/tools/fields.js
CHANGED
|
@@ -67,16 +67,28 @@ function relationshipField(name) {
|
|
|
67
67
|
* Read-only. Builds on the always-available sampling schema and normalizes it
|
|
68
68
|
* into a flat, per-field list with inferred hints. Always `approximate: true`.
|
|
69
69
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
70
|
+
* The entity type is accepted as either `type` or `entityType` (#116): the
|
|
71
|
+
* sibling tools (get_entity_schema, entity_create/update, resolve_reference) use
|
|
72
|
+
* `entityType`, and passing that name here previously slipped through as
|
|
73
|
+
* `undefined` and surfaced a misleading access-denied error.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} args - { site?, type|entityType, bundle? }. `bundle` defaults
|
|
76
|
+
* to the entity type (matching Drupal's single-bundle types, e.g. `user`).
|
|
72
77
|
* @returns {Promise<{entityType: string, bundle: string, resourceType?: string,
|
|
73
78
|
* approximate: true, fieldCount: number, fields: object[], note: string,
|
|
74
79
|
* authoritativeSource: string}>}
|
|
75
80
|
* @throws {SecurityError} If reading the type/bundle is not permitted.
|
|
81
|
+
* @throws {Error} If no entity type is given under either name.
|
|
76
82
|
*/
|
|
77
|
-
async function describeFields({ site: siteName, type, bundle }) {
|
|
78
|
-
const entityType = type;
|
|
79
|
-
|
|
83
|
+
async function describeFields({ site: siteName, type, entityType: entityTypeArg, bundle }) {
|
|
84
|
+
const entityType = type ?? entityTypeArg;
|
|
85
|
+
if (!entityType) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"drupal_describe_fields requires an entity type. Pass `type` (or its alias `entityType`), " +
|
|
88
|
+
"e.g. { type: \"node\", bundle: \"article\" }."
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const resolvedBundle = bundle || entityType;
|
|
80
92
|
|
|
81
93
|
const site = getSiteConfig(siteName);
|
|
82
94
|
const sec = resolveSecurityConfig(site);
|
|
@@ -120,10 +132,11 @@ export const definitions = [
|
|
|
120
132
|
"bridge (Field API). Use this before creating/updating entities to learn field names.",
|
|
121
133
|
inputSchema: {
|
|
122
134
|
type: "object",
|
|
123
|
-
required: ["site"
|
|
135
|
+
required: ["site"],
|
|
124
136
|
properties: {
|
|
125
137
|
site: { type: "string", description: "Configured site name." },
|
|
126
|
-
type: { type: "string", description: "Entity type machine name, e.g. 'node', 'taxonomy_term', 'user', 'media'." },
|
|
138
|
+
type: { type: "string", description: "Entity type machine name, e.g. 'node', 'taxonomy_term', 'user', 'media'. Alias: `entityType` (as used by the sibling tools)." },
|
|
139
|
+
entityType: { type: "string", description: "Alias for `type` — accepted for parity with get_entity_schema / entity_create / entity_update / resolve_reference." },
|
|
127
140
|
bundle: { type: "string", description: "Bundle machine name, e.g. 'article'. Defaults to the entity type for single-bundle types (e.g. 'user')." },
|
|
128
141
|
},
|
|
129
142
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import { getSiteConfig } from "../lib/config.js";
|
|
11
11
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
12
|
-
import { resolveSecurityConfig, redactCanonicalEntity, assertWriteAllowed } from "../lib/security.js";
|
|
12
|
+
import { resolveSecurityConfig, redactCanonicalEntity, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
|
|
13
|
+
import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
13
14
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
14
15
|
|
|
15
16
|
/** Fallback language for an alias when the node exposes none. */
|
|
@@ -215,10 +216,14 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
|
|
|
215
216
|
* If a moderated bundle still receives `status` (the safe default), the JSON:API
|
|
216
217
|
* backend transparently retries without it — see jsonapi.js.
|
|
217
218
|
*
|
|
218
|
-
*
|
|
219
|
+
* Entity-reference fields (taxonomy, related content, media) must be passed in
|
|
220
|
+
* `relationships` (JSON:API shape), not `fields`; Drupal rejects reference fields
|
|
221
|
+
* sent as attributes (#115).
|
|
222
|
+
*
|
|
223
|
+
* @param {object} args - { site?, type, title, body?, summary?, status?, moderationState?, fields?, relationships? }.
|
|
219
224
|
* @returns {Promise<object>} The created node descriptor from the backend.
|
|
220
225
|
*/
|
|
221
|
-
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, dryRun = false }) {
|
|
226
|
+
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
222
227
|
const site = getSiteConfig(siteName);
|
|
223
228
|
const attributes = { title, ...fields };
|
|
224
229
|
if (moderationState !== undefined) {
|
|
@@ -228,18 +233,19 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
228
233
|
}
|
|
229
234
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
230
235
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
231
|
-
|
|
236
|
+
assertPublishAllowed(resolveSecurityConfig(site), attributes);
|
|
237
|
+
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
|
|
232
238
|
const backend = await resolveBackend(site);
|
|
233
239
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
234
240
|
// `path` is omitted so pathauto generates the alias (DEV-116).
|
|
235
241
|
const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
|
|
236
242
|
if (pathAttr === undefined) delete attributes.path;
|
|
237
243
|
else attributes.path = pathAttr;
|
|
238
|
-
const created = await backend.createEntity({ entityType: "node", bundle: type, attributes });
|
|
244
|
+
const created = await backend.createEntity({ entityType: "node", bundle: type, attributes, relationships });
|
|
239
245
|
// Honest response: re-read so the persisted alias (explicit or pathauto-generated)
|
|
240
246
|
// is reflected rather than the pre-alias write response.
|
|
241
247
|
const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
|
|
242
|
-
return fresh ?? created;
|
|
248
|
+
return shapeWriteResponse(fresh ?? created, returning);
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
/**
|
|
@@ -256,10 +262,12 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
256
262
|
* the current alias is read back and re-pinned (`{ alias, pathauto: 0 }`). Pass
|
|
257
263
|
* `fields.path` explicitly to set/replace the alias yourself.
|
|
258
264
|
*
|
|
259
|
-
*
|
|
265
|
+
* Entity-reference fields go in `relationships` (JSON:API shape), not `fields` (#115).
|
|
266
|
+
*
|
|
267
|
+
* @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields?, relationships? }.
|
|
260
268
|
* @returns {Promise<object>} The updated node descriptor.
|
|
261
269
|
*/
|
|
262
|
-
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, dryRun = false }) {
|
|
270
|
+
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
263
271
|
const site = getSiteConfig(siteName);
|
|
264
272
|
const attributes = { ...fields };
|
|
265
273
|
if (title !== undefined) attributes.title = title;
|
|
@@ -267,7 +275,8 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
267
275
|
else if (status !== undefined) attributes.status = status;
|
|
268
276
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
269
277
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
270
|
-
|
|
278
|
+
assertPublishAllowed(resolveSecurityConfig(site), attributes);
|
|
279
|
+
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
|
|
271
280
|
const backend = await resolveBackend(site);
|
|
272
281
|
const sec = resolveSecurityConfig(site);
|
|
273
282
|
// Alias handling (DEV-116): an explicit `path.alias` is set in place by
|
|
@@ -277,13 +286,13 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
277
286
|
const { pathAttr, redirect } = await resolvePathWrite({ backend, type, id, providedPath: attributes.path, isCreate: false });
|
|
278
287
|
if (pathAttr === undefined) delete attributes.path;
|
|
279
288
|
else attributes.path = pathAttr;
|
|
280
|
-
await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
289
|
+
await backend.updateEntity({ entityType: "node", bundle: type, id, attributes, relationships });
|
|
281
290
|
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
282
291
|
// Honest response: re-read persisted state so the returned `url` is the alias
|
|
283
292
|
// that actually resolves, never the just-sent value.
|
|
284
293
|
const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
|
|
285
|
-
if (fresh && redirectResult) return { ...fresh, _redirect: redirectResult };
|
|
286
|
-
return fresh ?? { id };
|
|
294
|
+
if (fresh && redirectResult) return shapeWriteResponse({ ...fresh, _redirect: redirectResult }, returning);
|
|
295
|
+
return shapeWriteResponse(fresh ?? { id }, returning);
|
|
287
296
|
}
|
|
288
297
|
|
|
289
298
|
/**
|
|
@@ -350,7 +359,7 @@ export const definitions = [
|
|
|
350
359
|
},
|
|
351
360
|
{
|
|
352
361
|
name: "drupal_create_node",
|
|
353
|
-
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.",
|
|
362
|
+
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`.",
|
|
354
363
|
inputSchema: {
|
|
355
364
|
type: "object", required: ["type", "title"],
|
|
356
365
|
properties: {
|
|
@@ -361,14 +370,16 @@ export const definitions = [
|
|
|
361
370
|
summary: { type: "string", description: "Body summary / teaser" },
|
|
362
371
|
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." },
|
|
363
372
|
moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
|
|
364
|
-
fields: { type: "object", description: "
|
|
373
|
+
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`." },
|
|
374
|
+
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value: { field_resource_type: { data: { type: 'taxonomy_term--resource_type', id: '<uuid>' } } }. Multi-value: { field_tags: { data: [{ type: 'taxonomy_term--tags', id: '<uuid>' }] } }." },
|
|
365
375
|
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the write without committing." },
|
|
376
|
+
returning: RETURNING_SCHEMA,
|
|
366
377
|
},
|
|
367
378
|
},
|
|
368
379
|
},
|
|
369
380
|
{
|
|
370
381
|
name: "drupal_update_node",
|
|
371
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status.",
|
|
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`.",
|
|
372
383
|
inputSchema: {
|
|
373
384
|
type: "object", required: ["type", "id"],
|
|
374
385
|
properties: {
|
|
@@ -380,8 +391,10 @@ export const definitions = [
|
|
|
380
391
|
summary: { type: "string" },
|
|
381
392
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
382
393
|
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
|
|
383
|
-
fields: { type: "object" },
|
|
394
|
+
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
|
|
395
|
+
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 }, …] }." },
|
|
384
396
|
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
|
|
397
|
+
returning: RETURNING_SCHEMA,
|
|
385
398
|
},
|
|
386
399
|
},
|
|
387
400
|
},
|
package/src/tools/reports.js
CHANGED
|
@@ -12,6 +12,7 @@ import { getSiteConfig } from "../lib/config.js";
|
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
13
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
14
14
|
import { collectEntities, gatedReport, fieldValue, daysSince } from "../lib/reports-support.js";
|
|
15
|
+
import { fetchRenderedMetaDescriptions } from "../lib/metatag-audit.js";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Report implementations
|
|
@@ -395,8 +396,13 @@ async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
|
|
|
395
396
|
* (>60 too long, <20 too short) and the 300-word thin-content floor are SEO
|
|
396
397
|
* heuristics, not hard Drupal limits.
|
|
397
398
|
*
|
|
399
|
+
* The meta-description check reports its `metaSource`: "graphql" (rendered
|
|
400
|
+
* Metatag output — the accurate default), "jsonapi" (a plain description field
|
|
401
|
+
* on non-Metatag sites), or "unavailable" (neither could be read, in which case
|
|
402
|
+
* `missingMetaDescription` is `{ unavailable: true, reason }` — never a false 0).
|
|
403
|
+
*
|
|
398
404
|
* @param {object} args - { site?, type?, sampleSize? }.
|
|
399
|
-
* @returns {Promise<object>} Issue lists keyed by category,
|
|
405
|
+
* @returns {Promise<object>} Issue lists keyed by category, plus `metaSource`.
|
|
400
406
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
401
407
|
*/
|
|
402
408
|
async function seoAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
@@ -412,26 +418,74 @@ async function seoAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
|
412
418
|
{ entityType: "node", bundle: contentType, filters: [{ field: "status", op: "eq", value: true }] },
|
|
413
419
|
sampleSize
|
|
414
420
|
);
|
|
421
|
+
|
|
422
|
+
// Meta descriptions: prefer the *rendered* value from GraphQL Compose's
|
|
423
|
+
// normalized `metatag` field (defaults + overrides, resolved), which is the
|
|
424
|
+
// only source that reflects what the frontend emits. Fall back to a plain
|
|
425
|
+
// JSON:API description field for non-Metatag sites. The computed `metatag`
|
|
426
|
+
// field is deliberately NOT read over JSON:API — it is an unresolved
|
|
427
|
+
// placeholder there and counting it as "present" hides every gap (issue #120).
|
|
428
|
+
const rendered = await fetchRenderedMetaDescriptions(site, entities);
|
|
429
|
+
const metaSource = rendered.source === "graphql" ? "graphql" : resolveJsonapiMetaSource(entities);
|
|
430
|
+
|
|
415
431
|
for (const n of entities) {
|
|
416
432
|
const title = n.title ?? "";
|
|
417
433
|
const bodyField = fieldValue(n, ["body"]);
|
|
418
434
|
const body = (bodyField && typeof bodyField === "object" ? bodyField.value : bodyField) ?? "";
|
|
419
|
-
const meta = fieldValue(n, ["metaDescription", "field_meta_description", "metatag"]) ?? null;
|
|
420
435
|
const wordCount = String(body).replace(/<[^>]+>/g, " ").split(/\s+/).filter(Boolean).length;
|
|
421
|
-
if (
|
|
436
|
+
if (metaSource === "graphql") {
|
|
437
|
+
// Only nodes whose route resolved are knowable; skip the rest.
|
|
438
|
+
const hit = rendered.byId.get(n.id);
|
|
439
|
+
if (hit && !hit.description) issues.get("missingMetaDescription").push({ id: n.id, title });
|
|
440
|
+
} else if (metaSource === "jsonapi") {
|
|
441
|
+
if (!jsonapiMetaDescription(n)) issues.get("missingMetaDescription").push({ id: n.id, title });
|
|
442
|
+
}
|
|
422
443
|
if (title.length > 60) issues.get("titleTooLong").push({ id: n.id, title, length: title.length });
|
|
423
444
|
if (title.length < 20) issues.get("titleTooShort").push({ id: n.id, title, length: title.length });
|
|
424
445
|
if (wordCount < 300 && wordCount > 0) issues.get("thinContent").push({ id: n.id, title, wordCount });
|
|
425
446
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
447
|
+
|
|
448
|
+
const issuesOut = Object.fromEntries(issueKeys.map((k) => {
|
|
449
|
+
const list = issues.get(k);
|
|
450
|
+
return [k, { count: list.length, nodes: list }];
|
|
451
|
+
}));
|
|
452
|
+
if (metaSource === "unavailable") {
|
|
453
|
+
// Do NOT emit a false "0 missing": we could not read a meta description at all.
|
|
454
|
+
issuesOut.missingMetaDescription = {
|
|
455
|
+
unavailable: true,
|
|
456
|
+
reason: rendered.reason ?? "no rendered metatag or JSON:API description field available",
|
|
457
|
+
count: null,
|
|
458
|
+
nodes: [],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return { contentType, scanned: entities.length, approximate: false, metaSource, issues: issuesOut };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Read a plain JSON:API description field (the non-Metatag path). The computed
|
|
466
|
+
* `metatag` field is excluded on purpose — it does not resolve over JSON:API.
|
|
467
|
+
*
|
|
468
|
+
* @param {object} n Canonical entity.
|
|
469
|
+
* @returns {string|undefined} Trimmed description, "" when the field is present
|
|
470
|
+
* but empty, or undefined when no such field exists on the entity.
|
|
471
|
+
*/
|
|
472
|
+
function jsonapiMetaDescription(n) {
|
|
473
|
+
const v = fieldValue(n, ["metaDescription", "field_meta_description"]);
|
|
474
|
+
if (v === undefined) return undefined;
|
|
475
|
+
if (v && typeof v === "object") return String(v.value ?? "").trim();
|
|
476
|
+
return String(v ?? "").trim();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Decide whether a JSON:API description field is usable across the sample. If no
|
|
481
|
+
* node exposes the field at all, the field does not exist and we must report the
|
|
482
|
+
* meta check as unavailable rather than flagging every node as missing.
|
|
483
|
+
*
|
|
484
|
+
* @param {object[]} entities Canonical entities.
|
|
485
|
+
* @returns {"jsonapi"|"unavailable"}
|
|
486
|
+
*/
|
|
487
|
+
function resolveJsonapiMetaSource(entities) {
|
|
488
|
+
return entities.some((n) => jsonapiMetaDescription(n) !== undefined) ? "jsonapi" : "unavailable";
|
|
435
489
|
}
|
|
436
490
|
|
|
437
491
|
/**
|
|
@@ -580,7 +634,7 @@ export const definitions = [
|
|
|
580
634
|
},
|
|
581
635
|
{
|
|
582
636
|
name: "drupal_report_seo_audit",
|
|
583
|
-
description: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category.",
|
|
637
|
+
description: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category. Meta descriptions use the rendered Metatag output via GraphQL when available (reported as `metaSource`); when no description source is readable it reports the meta check as unavailable rather than a false zero.",
|
|
584
638
|
inputSchema: {
|
|
585
639
|
type: "object",
|
|
586
640
|
properties: {
|