drupal-mcp-connector 2.7.3 → 2.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/.agents/commands/drupal-audit-config-best-practices.md +15 -0
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +4 -5
- package/.agents/commands/drupal-block-user.md +19 -0
- package/{.claude → .agents}/commands/drupal-bulk-create.md +4 -5
- package/{.claude → .agents}/commands/drupal-bulk-update.md +4 -5
- package/.agents/commands/drupal-config-get.md +18 -0
- package/.agents/commands/drupal-config-list.md +16 -0
- package/.agents/commands/drupal-config-set.md +19 -0
- package/.agents/commands/drupal-content-by-moderation-state.md +21 -0
- package/.agents/commands/drupal-create-block.md +20 -0
- package/{.claude → .agents}/commands/drupal-create-media.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-node.md +7 -8
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-redirect.md +4 -5
- package/.agents/commands/drupal-create-taxonomy-term.md +22 -0
- package/{.claude → .agents}/commands/drupal-create-translation.md +4 -5
- package/.agents/commands/drupal-create-user.md +23 -0
- package/.agents/commands/drupal-delete-media.md +21 -0
- package/.agents/commands/drupal-delete-node.md +22 -0
- package/.agents/commands/drupal-delete-taxonomy-term.md +21 -0
- package/{.claude → .agents}/commands/drupal-describe-fields.md +4 -5
- package/.agents/commands/drupal-drush-cache-rebuild.md +15 -0
- package/.agents/commands/drupal-drush-config-export.md +15 -0
- package/.agents/commands/drupal-drush-config-import.md +15 -0
- package/.agents/commands/drupal-drush-config-status.md +15 -0
- package/.agents/commands/drupal-drush-cron.md +15 -0
- package/.agents/commands/drupal-drush-module-disable.md +20 -0
- package/.agents/commands/drupal-drush-module-enable.md +18 -0
- package/.agents/commands/drupal-drush-module-list.md +16 -0
- package/.agents/commands/drupal-drush-security-updates.md +15 -0
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +4 -5
- package/.agents/commands/drupal-drush-status.md +15 -0
- package/.agents/commands/drupal-drush-updatedb.md +15 -0
- package/.agents/commands/drupal-drush-user-create.md +21 -0
- package/.agents/commands/drupal-drush-user-list.md +18 -0
- package/.agents/commands/drupal-drush-watchdog.md +18 -0
- package/{.claude → .agents}/commands/drupal-entity-create.md +4 -5
- package/.agents/commands/drupal-entity-delete.md +23 -0
- package/.agents/commands/drupal-entity-get.md +21 -0
- package/{.claude → .agents}/commands/drupal-entity-list.md +4 -5
- package/{.claude → .agents}/commands/drupal-entity-update.md +7 -8
- package/.agents/commands/drupal-find-orphaned-media.md +17 -0
- package/.agents/commands/drupal-get-entity-schema.md +19 -0
- package/.agents/commands/drupal-get-media.md +19 -0
- package/.agents/commands/drupal-get-node.md +19 -0
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-get-revision.md +4 -5
- package/.agents/commands/drupal-get-taxonomy-term.md +19 -0
- package/.agents/commands/drupal-get-taxonomy-terms.md +20 -0
- package/.agents/commands/drupal-get-user-by-name.md +18 -0
- package/.agents/commands/drupal-get-user.md +18 -0
- package/{.claude → .agents}/commands/drupal-governance-status.md +4 -5
- package/.agents/commands/drupal-graphql-introspect.md +16 -0
- package/{.claude → .agents}/commands/drupal-graphql.md +4 -5
- package/{.claude → .agents}/commands/drupal-list-blocks.md +4 -5
- package/.agents/commands/drupal-list-content-types.md +15 -0
- package/.agents/commands/drupal-list-entity-types.md +15 -0
- package/.agents/commands/drupal-list-media-types.md +15 -0
- package/.agents/commands/drupal-list-media.md +20 -0
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +4 -5
- package/.agents/commands/drupal-list-moderation-states.md +19 -0
- package/{.claude → .agents}/commands/drupal-list-nodes.md +4 -5
- package/{.claude → .agents}/commands/drupal-list-revisions.md +4 -5
- package/.agents/commands/drupal-list-roles.md +15 -0
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +4 -5
- package/.agents/commands/drupal-list-users.md +19 -0
- package/.agents/commands/drupal-list-vocabularies.md +15 -0
- package/.agents/commands/drupal-mcp-whoami.md +15 -0
- package/.agents/commands/drupal-report-404-log.md +16 -0
- package/.agents/commands/drupal-report-accessibility-audit.md +17 -0
- package/.agents/commands/drupal-report-alias-coverage.md +17 -0
- package/.agents/commands/drupal-report-broken-embeds.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +4 -5
- package/.agents/commands/drupal-report-cache-config.md +15 -0
- package/.agents/commands/drupal-report-config-drift.md +15 -0
- package/.agents/commands/drupal-report-content-by-author.md +17 -0
- package/.agents/commands/drupal-report-content-summary.md +15 -0
- package/.agents/commands/drupal-report-duplicate-content.md +17 -0
- package/.agents/commands/drupal-report-field-completeness.md +20 -0
- package/.agents/commands/drupal-report-menu-integrity.md +16 -0
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +4 -5
- package/.agents/commands/drupal-report-module-audit.md +15 -0
- package/.agents/commands/drupal-report-orphan-pages.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +4 -5
- package/.agents/commands/drupal-report-permission-audit.md +15 -0
- package/.agents/commands/drupal-report-pii-exposure.md +18 -0
- package/.agents/commands/drupal-report-readability.md +18 -0
- package/.agents/commands/drupal-report-recently-published.md +17 -0
- package/.agents/commands/drupal-report-redirect-health.md +16 -0
- package/.agents/commands/drupal-report-revision-hotspots.md +17 -0
- package/.agents/commands/drupal-report-scheduled-content.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +4 -5
- package/.agents/commands/drupal-report-seo-meta-coverage.md +18 -0
- package/.agents/commands/drupal-report-stale-content.md +19 -0
- package/.agents/commands/drupal-report-status-report.md +16 -0
- package/.agents/commands/drupal-report-taxonomy-usage.md +21 -0
- package/.agents/commands/drupal-report-text-format-audit.md +15 -0
- package/.agents/commands/drupal-report-translation-coverage.md +18 -0
- package/.agents/commands/drupal-report-unpublished.md +17 -0
- package/.agents/commands/drupal-report-user-activity.md +17 -0
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +19 -0
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +4 -5
- package/{.claude → .agents}/commands/drupal-revert-revision.md +4 -5
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +4 -5
- package/.agents/commands/drupal-search-content.md +21 -0
- package/.agents/commands/drupal-search.md +20 -0
- package/.agents/commands/drupal-security-info.md +15 -0
- package/.agents/commands/drupal-set-moderation-state.md +20 -0
- package/.agents/commands/drupal-site-info.md +15 -0
- package/.agents/commands/drupal-update-media.md +22 -0
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +4 -5
- package/{.claude → .agents}/commands/drupal-update-node.md +10 -11
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-update-redirect.md +4 -5
- package/.agents/commands/drupal-update-taxonomy-term.md +22 -0
- package/.agents/commands/drupal-update-user.md +24 -0
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +4 -5
- package/{.claude → .agents}/commands/drupal-upload-file.md +4 -5
- package/CHANGELOG.md +80 -0
- package/README.md +23 -12
- package/config/config.example.json +1 -1
- package/package.json +4 -2
- package/scripts/generate-commands.js +42 -21
- package/scripts/install-commands.js +157 -0
- package/src/lib/backends/backend-interface.js +20 -1
- package/src/lib/backends/jsonapi.js +47 -4
- package/src/lib/body-summary.js +60 -0
- package/src/lib/data-flow.js +437 -0
- package/src/lib/dispatch.js +99 -30
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/operations.js +39 -2
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +15 -1
- package/src/lib/server-tools.js +9 -0
- package/src/lib/site-target.js +78 -0
- package/src/lib/tool-prompts.js +2 -1
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/config.js +5 -2
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +24 -3
- package/src/tools/moderation.js +88 -9
- package/src/tools/nodes.js +55 -43
- package/.claude/commands/drupal-audit-config-best-practices.md +0 -16
- package/.claude/commands/drupal-block-user.md +0 -20
- package/.claude/commands/drupal-config-get.md +0 -19
- package/.claude/commands/drupal-config-list.md +0 -17
- package/.claude/commands/drupal-config-set.md +0 -20
- package/.claude/commands/drupal-content-by-moderation-state.md +0 -22
- package/.claude/commands/drupal-create-block.md +0 -21
- package/.claude/commands/drupal-create-taxonomy-term.md +0 -23
- package/.claude/commands/drupal-create-user.md +0 -24
- package/.claude/commands/drupal-delete-media.md +0 -22
- package/.claude/commands/drupal-delete-node.md +0 -23
- package/.claude/commands/drupal-delete-taxonomy-term.md +0 -22
- package/.claude/commands/drupal-drush-cache-rebuild.md +0 -16
- package/.claude/commands/drupal-drush-config-export.md +0 -16
- package/.claude/commands/drupal-drush-config-import.md +0 -16
- package/.claude/commands/drupal-drush-config-status.md +0 -16
- package/.claude/commands/drupal-drush-cron.md +0 -16
- package/.claude/commands/drupal-drush-module-disable.md +0 -21
- package/.claude/commands/drupal-drush-module-enable.md +0 -19
- package/.claude/commands/drupal-drush-module-list.md +0 -17
- package/.claude/commands/drupal-drush-security-updates.md +0 -16
- package/.claude/commands/drupal-drush-status.md +0 -16
- package/.claude/commands/drupal-drush-updatedb.md +0 -16
- package/.claude/commands/drupal-drush-user-create.md +0 -22
- package/.claude/commands/drupal-drush-user-list.md +0 -19
- package/.claude/commands/drupal-drush-watchdog.md +0 -19
- package/.claude/commands/drupal-entity-delete.md +0 -24
- package/.claude/commands/drupal-entity-get.md +0 -22
- package/.claude/commands/drupal-find-orphaned-media.md +0 -18
- package/.claude/commands/drupal-get-entity-schema.md +0 -20
- package/.claude/commands/drupal-get-media.md +0 -20
- package/.claude/commands/drupal-get-node.md +0 -20
- package/.claude/commands/drupal-get-taxonomy-term.md +0 -20
- package/.claude/commands/drupal-get-taxonomy-terms.md +0 -21
- package/.claude/commands/drupal-get-user-by-name.md +0 -19
- package/.claude/commands/drupal-get-user.md +0 -19
- package/.claude/commands/drupal-graphql-introspect.md +0 -17
- package/.claude/commands/drupal-list-content-types.md +0 -16
- package/.claude/commands/drupal-list-entity-types.md +0 -16
- package/.claude/commands/drupal-list-media-types.md +0 -16
- package/.claude/commands/drupal-list-media.md +0 -21
- package/.claude/commands/drupal-list-moderation-states.md +0 -20
- package/.claude/commands/drupal-list-roles.md +0 -16
- package/.claude/commands/drupal-list-users.md +0 -20
- package/.claude/commands/drupal-list-vocabularies.md +0 -16
- package/.claude/commands/drupal-mcp-whoami.md +0 -16
- package/.claude/commands/drupal-report-404-log.md +0 -17
- package/.claude/commands/drupal-report-accessibility-audit.md +0 -18
- package/.claude/commands/drupal-report-alias-coverage.md +0 -18
- package/.claude/commands/drupal-report-broken-embeds.md +0 -18
- package/.claude/commands/drupal-report-cache-config.md +0 -16
- package/.claude/commands/drupal-report-config-drift.md +0 -16
- package/.claude/commands/drupal-report-content-by-author.md +0 -18
- package/.claude/commands/drupal-report-content-summary.md +0 -16
- package/.claude/commands/drupal-report-duplicate-content.md +0 -18
- package/.claude/commands/drupal-report-field-completeness.md +0 -21
- package/.claude/commands/drupal-report-menu-integrity.md +0 -17
- package/.claude/commands/drupal-report-module-audit.md +0 -16
- package/.claude/commands/drupal-report-orphan-pages.md +0 -18
- package/.claude/commands/drupal-report-permission-audit.md +0 -16
- package/.claude/commands/drupal-report-pii-exposure.md +0 -19
- package/.claude/commands/drupal-report-readability.md +0 -19
- package/.claude/commands/drupal-report-recently-published.md +0 -18
- package/.claude/commands/drupal-report-redirect-health.md +0 -17
- package/.claude/commands/drupal-report-revision-hotspots.md +0 -18
- package/.claude/commands/drupal-report-scheduled-content.md +0 -18
- package/.claude/commands/drupal-report-seo-meta-coverage.md +0 -19
- package/.claude/commands/drupal-report-stale-content.md +0 -20
- package/.claude/commands/drupal-report-status-report.md +0 -17
- package/.claude/commands/drupal-report-taxonomy-usage.md +0 -22
- package/.claude/commands/drupal-report-text-format-audit.md +0 -16
- package/.claude/commands/drupal-report-translation-coverage.md +0 -19
- package/.claude/commands/drupal-report-unpublished.md +0 -18
- package/.claude/commands/drupal-report-user-activity.md +0 -18
- package/.claude/commands/drupal-report-workflow-bottlenecks.md +0 -20
- package/.claude/commands/drupal-search-content.md +0 -22
- package/.claude/commands/drupal-search.md +0 -21
- package/.claude/commands/drupal-security-info.md +0 -16
- package/.claude/commands/drupal-set-moderation-state.md +0 -21
- package/.claude/commands/drupal-site-info.md +0 -16
- package/.claude/commands/drupal-update-media.md +0 -23
- package/.claude/commands/drupal-update-taxonomy-term.md +0 -23
- package/.claude/commands/drupal-update-user.md +0 -25
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JSON:API PATCH preflight
|
|
2
|
+
* JSON:API PATCH preflight and working-copy targeting (#201 / #166).
|
|
3
3
|
*
|
|
4
4
|
* `EntityResource::patchIndividual()` rejects a canonical PATCH when the
|
|
5
5
|
* stored entity is not both the latest and the default revision. That check
|
|
@@ -8,23 +8,33 @@
|
|
|
8
8
|
* often `setNewRevision`) — so the probe must fail *after* the guard and
|
|
9
9
|
* *before* save. Core next compares `data.id` to the URL entity UUID; a
|
|
10
10
|
* well-formed but non-matching id yields 400 "does not match the ID in the
|
|
11
|
-
* payload" with no row written.
|
|
12
|
-
* `rel:working-copy` aliases can disagree with storage (a revision row with
|
|
13
|
-
* no `content_moderation_state`), which is how every read tool reports
|
|
14
|
-
* clean and the write then 400s.
|
|
11
|
+
* payload" with no row written.
|
|
15
12
|
*
|
|
16
|
-
*
|
|
17
|
-
* the node is writable. That is the #201 lie.
|
|
13
|
+
* Two distinct cases share that core 400:
|
|
18
14
|
*
|
|
19
|
-
*
|
|
20
|
-
* `?resourceVersion=rel:working-copy`.
|
|
15
|
+
* - **#166** — `rel:working-copy` resolves. PATCH (and the dryRun probe)
|
|
16
|
+
* target `?resourceVersion=rel:working-copy`. Do not discard, do not
|
|
17
|
+
* retry the canonical URL, and do not tell the caller to publish first.
|
|
18
|
+
* - **#201** — the working-copy alias does not resolve, but core still
|
|
19
|
+
* blocks. That is a stray revision row. Refuse with revision-surgery
|
|
20
|
+
* language. `workingCopy: null` is not proof the node is writable.
|
|
21
|
+
*
|
|
22
|
+
* A working-copy 400 *after* targeting the working copy is a stale or
|
|
23
|
+
* concurrent write — refuse, do not fall back to discard.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
27
|
+
import { entityRevisionId } from "./write-revision.js";
|
|
24
28
|
|
|
25
29
|
/** Stable error code for a core working-copy / not-latest-revision block. */
|
|
26
30
|
export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
|
|
27
31
|
|
|
32
|
+
/** Stable error code when a working-copy PATCH itself is rejected. */
|
|
33
|
+
export const PATCH_WORKING_COPY_STALE_CODE = "PATCH_WORKING_COPY_STALE";
|
|
34
|
+
|
|
35
|
+
/** Stable error code when the working-copy resource does not match the target. */
|
|
36
|
+
export const PATCH_TARGET_AMBIGUOUS_CODE = "PATCH_TARGET_AMBIGUOUS";
|
|
37
|
+
|
|
28
38
|
const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -39,61 +49,75 @@ export const PATCH_BLOCKED_MESSAGE =
|
|
|
39
49
|
"See connector #201. Do not retry the same canonical PATCH.";
|
|
40
50
|
|
|
41
51
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
? workingCopy.fields
|
|
50
|
-
: {};
|
|
51
|
-
const attrs = workingCopy.attributes && typeof workingCopy.attributes === "object"
|
|
52
|
-
? workingCopy.attributes
|
|
53
|
-
: {};
|
|
54
|
-
const raw = workingCopy.vid
|
|
55
|
-
?? fields.drupal_internal__vid
|
|
56
|
-
?? attrs.drupal_internal__vid
|
|
57
|
-
?? workingCopy.drupal_internal__vid;
|
|
58
|
-
if (raw === undefined || raw === null || raw === "") return null;
|
|
59
|
-
const n = Number(raw);
|
|
60
|
-
return Number.isFinite(n) ? n : raw;
|
|
61
|
-
}
|
|
52
|
+
* A working-copy-targeted PATCH (or its probe) hit core's working-copy guard.
|
|
53
|
+
* That is a stale or concurrent conflict, not an invitation to discard.
|
|
54
|
+
*/
|
|
55
|
+
export const PATCH_WORKING_COPY_STALE_MESSAGE =
|
|
56
|
+
"This entity's working-copy revision could not be updated (stale or concurrent write). " +
|
|
57
|
+
"The connector will not retry the canonical URL or discard the draft. " +
|
|
58
|
+
"Re-read rel:working-copy and retry, or resolve the conflict in Drupal. See connector #166.";
|
|
62
59
|
|
|
63
60
|
/**
|
|
64
61
|
* Operator message for a core working-copy 400.
|
|
65
|
-
* A resolvable working copy is
|
|
66
|
-
*
|
|
67
|
-
* @param {?object} [workingCopy]
|
|
62
|
+
* A resolvable working copy is edited in place (#166) — this message is only
|
|
63
|
+
* for the invisible-row case (#201).
|
|
68
64
|
* @returns {string}
|
|
69
65
|
*/
|
|
70
|
-
export function patchBlockedMessage(
|
|
71
|
-
if (workingCopy) {
|
|
72
|
-
const vid = workingCopyVid(workingCopy);
|
|
73
|
-
const which = vid !== null && vid !== undefined ? ` (vid ${vid})` : "";
|
|
74
|
-
return `This node has a pending draft${which}. Publish or discard it `
|
|
75
|
-
+ "before a canonical PATCH.";
|
|
76
|
-
}
|
|
66
|
+
export function patchBlockedMessage() {
|
|
77
67
|
return PATCH_BLOCKED_MESSAGE;
|
|
78
68
|
}
|
|
79
69
|
|
|
80
70
|
/**
|
|
81
|
-
* Thrown when the core working-copy PATCH guard rejects a write
|
|
71
|
+
* Thrown when the core working-copy PATCH guard rejects a canonical write
|
|
72
|
+
* (or its probe) and no working copy is addressable (#201).
|
|
82
73
|
*/
|
|
83
74
|
export class PatchBlockedError extends Error {
|
|
84
75
|
/**
|
|
85
76
|
* @param {?Error} [cause] The original Drupal 400.
|
|
86
|
-
* @param {{workingCopy?: ?object}} [options]
|
|
87
77
|
*/
|
|
88
|
-
constructor(cause
|
|
89
|
-
super(
|
|
78
|
+
constructor(cause) {
|
|
79
|
+
super(PATCH_BLOCKED_MESSAGE);
|
|
90
80
|
this.name = "PatchBlockedError";
|
|
91
81
|
this.code = PATCH_BLOCKED_CODE;
|
|
92
|
-
if (workingCopy) this.workingCopyVid = workingCopyVid(workingCopy);
|
|
93
82
|
if (cause) this.cause = cause;
|
|
94
83
|
}
|
|
95
84
|
}
|
|
96
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Thrown when a working-copy-targeted PATCH or probe is rejected (#166).
|
|
88
|
+
*/
|
|
89
|
+
export class WorkingCopyStaleError extends Error {
|
|
90
|
+
/**
|
|
91
|
+
* @param {?Error} [cause] The original Drupal 400.
|
|
92
|
+
*/
|
|
93
|
+
constructor(cause) {
|
|
94
|
+
super(PATCH_WORKING_COPY_STALE_MESSAGE);
|
|
95
|
+
this.name = "WorkingCopyStaleError";
|
|
96
|
+
this.code = PATCH_WORKING_COPY_STALE_CODE;
|
|
97
|
+
if (cause) this.cause = cause;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Thrown when `rel:working-copy` resolves to a different UUID than the
|
|
103
|
+
* entity being updated.
|
|
104
|
+
*/
|
|
105
|
+
export class PatchTargetAmbiguousError extends Error {
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} expectedId Requested entity UUID.
|
|
108
|
+
* @param {string} actualId Working-copy resource UUID.
|
|
109
|
+
*/
|
|
110
|
+
constructor(expectedId, actualId) {
|
|
111
|
+
super(
|
|
112
|
+
`The working-copy resource id (${actualId}) does not match the entity ` +
|
|
113
|
+
`being updated (${expectedId}). Refusing an ambiguous PATCH target. ` +
|
|
114
|
+
"See connector #166."
|
|
115
|
+
);
|
|
116
|
+
this.name = "PatchTargetAmbiguousError";
|
|
117
|
+
this.code = PATCH_TARGET_AMBIGUOUS_CODE;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
/**
|
|
98
122
|
* Whether an error is Drupal core's working-copy PATCH guard (core #2795279).
|
|
99
123
|
* @param {unknown} err
|
|
@@ -105,27 +129,22 @@ export function isWorkingCopyPatchError(err) {
|
|
|
105
129
|
|
|
106
130
|
/**
|
|
107
131
|
* Rewrite a core working-copy 400 into {@link PatchBlockedError}; otherwise
|
|
108
|
-
* return the original value.
|
|
109
|
-
* addressable so the message does not prescribe revision surgery.
|
|
132
|
+
* return the original value. Used only for the stray-revision (#201) case.
|
|
110
133
|
* @param {unknown} err
|
|
111
|
-
* @param {{workingCopy?: ?object}} [options]
|
|
112
134
|
* @returns {unknown}
|
|
113
135
|
*/
|
|
114
|
-
export function rewriteWorkingCopyPatchError(err
|
|
136
|
+
export function rewriteWorkingCopyPatchError(err) {
|
|
115
137
|
if (!isWorkingCopyPatchError(err)) return err;
|
|
116
|
-
return new PatchBlockedError(
|
|
117
|
-
err instanceof Error ? err : new Error(String(err)),
|
|
118
|
-
{ workingCopy },
|
|
119
|
-
);
|
|
138
|
+
return new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
/**
|
|
123
|
-
* Load `rel:working-copy` so a
|
|
142
|
+
* Load `rel:working-copy` so a write can target the pending draft (#166).
|
|
124
143
|
* @param {object} backend
|
|
125
144
|
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
126
145
|
* @returns {Promise<?object>}
|
|
127
146
|
*/
|
|
128
|
-
async function loadWorkingCopy(backend, { entityType, bundle, id }) {
|
|
147
|
+
export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
|
|
129
148
|
if (typeof backend?.getEntity !== "function") return null;
|
|
130
149
|
try {
|
|
131
150
|
const wc = await backend.getEntity({
|
|
@@ -137,6 +156,38 @@ async function loadWorkingCopy(backend, { entityType, bundle, id }) {
|
|
|
137
156
|
}
|
|
138
157
|
}
|
|
139
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Decide whether this PATCH should target `rel:working-copy`.
|
|
161
|
+
*
|
|
162
|
+
* A 200 from the working-copy alias is treated as an addressable draft unless
|
|
163
|
+
* both live and working vids are known and equal (the alias echoed the
|
|
164
|
+
* default revision). An id mismatch is refused rather than guessed.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} backend
|
|
167
|
+
* @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
|
|
168
|
+
* @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
|
|
169
|
+
* @throws {PatchTargetAmbiguousError}
|
|
170
|
+
*/
|
|
171
|
+
export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
|
|
172
|
+
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
|
|
173
|
+
let liveVid = entityRevisionId(existing);
|
|
174
|
+
if (workingCopy && (liveVid === null || liveVid === undefined) && typeof backend?.getEntity === "function") {
|
|
175
|
+
const live = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
|
|
176
|
+
liveVid = entityRevisionId(live);
|
|
177
|
+
}
|
|
178
|
+
if (!workingCopy) {
|
|
179
|
+
return { resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null };
|
|
180
|
+
}
|
|
181
|
+
if (workingCopy.id && workingCopy.id !== id) {
|
|
182
|
+
throw new PatchTargetAmbiguousError(id, workingCopy.id);
|
|
183
|
+
}
|
|
184
|
+
const workingVid = entityRevisionId(workingCopy);
|
|
185
|
+
if (workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid)) {
|
|
186
|
+
return { resourceVersion: undefined, workingCopy, liveVid, workingVid };
|
|
187
|
+
}
|
|
188
|
+
return { resourceVersion: "rel:working-copy", workingCopy, liveVid, workingVid };
|
|
189
|
+
}
|
|
190
|
+
|
|
140
191
|
/**
|
|
141
192
|
* Whether this update should run the PATCH probe.
|
|
142
193
|
* Skip unmoderated / non-revisionable bundles — the guard is about
|
|
@@ -170,14 +221,16 @@ export function isProbePassedWithoutSave(err) {
|
|
|
170
221
|
}
|
|
171
222
|
|
|
172
223
|
/**
|
|
173
|
-
* Probe the same guard core uses on the
|
|
224
|
+
* Probe the same guard core uses on the PATCH about to be sent.
|
|
174
225
|
*
|
|
175
226
|
* Sends a PATCH whose `data.id` does not match the URL entity. Core runs
|
|
176
227
|
* the working-copy check first; a match on that phrase means no row was
|
|
177
228
|
* written. An id-mismatch 400 (or deserialize 422) means the guard passed
|
|
178
229
|
* and save was not reached. A 2xx would have saved a revision and is
|
|
179
|
-
* treated as a probe failure.
|
|
180
|
-
*
|
|
230
|
+
* treated as a probe failure.
|
|
231
|
+
*
|
|
232
|
+
* When `resourceVersion` is `rel:working-copy`, the probe hits that same
|
|
233
|
+
* URL so dryRun cannot succeed when the real write would 400 (#166).
|
|
181
234
|
*
|
|
182
235
|
* @param {object} args
|
|
183
236
|
* @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
|
|
@@ -186,11 +239,12 @@ export function isProbePassedWithoutSave(err) {
|
|
|
186
239
|
* @param {string} args.id
|
|
187
240
|
* @param {?object} [args.existing]
|
|
188
241
|
* @param {object} [args.attributes]
|
|
242
|
+
* @param {?string} [args.resourceVersion]
|
|
189
243
|
* @returns {Promise<{probed: boolean, writable?: boolean|string, skipped?: string}>}
|
|
190
|
-
* @throws {PatchBlockedError} When the guard rejects the probe.
|
|
244
|
+
* @throws {PatchBlockedError|WorkingCopyStaleError} When the guard rejects the probe.
|
|
191
245
|
*/
|
|
192
246
|
export async function preflightPatchWritable({
|
|
193
|
-
backend, entityType, bundle, id, existing, attributes,
|
|
247
|
+
backend, entityType, bundle, id, existing, attributes, resourceVersion,
|
|
194
248
|
}) {
|
|
195
249
|
if (!shouldPreflightPatch({ existing, attributes })) {
|
|
196
250
|
return { probed: false };
|
|
@@ -198,7 +252,10 @@ export async function preflightPatchWritable({
|
|
|
198
252
|
if (typeof backend?.rawQuery !== "function" || typeof backend?.resourcePath !== "function") {
|
|
199
253
|
return { probed: false, skipped: "backend cannot issue a raw PATCH probe" };
|
|
200
254
|
}
|
|
201
|
-
|
|
255
|
+
let path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
256
|
+
if (resourceVersion) {
|
|
257
|
+
path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
|
|
258
|
+
}
|
|
202
259
|
const type = `${entityType}--${bundle}`;
|
|
203
260
|
const probeId = id === PATCH_PROBE_MISMATCH_ID
|
|
204
261
|
? "00000000-0000-4000-a000-000000000002"
|
|
@@ -217,11 +274,15 @@ export async function preflightPatchWritable({
|
|
|
217
274
|
);
|
|
218
275
|
} catch (err) {
|
|
219
276
|
if (isWorkingCopyPatchError(err)) {
|
|
277
|
+
const cause = err instanceof Error ? err : new Error(String(err));
|
|
278
|
+
if (resourceVersion === "rel:working-copy") {
|
|
279
|
+
throw new WorkingCopyStaleError(cause);
|
|
280
|
+
}
|
|
220
281
|
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
282
|
+
if (workingCopy) {
|
|
283
|
+
throw new WorkingCopyStaleError(cause);
|
|
284
|
+
}
|
|
285
|
+
throw new PatchBlockedError(cause);
|
|
225
286
|
}
|
|
226
287
|
if (isProbePassedWithoutSave(err)) {
|
|
227
288
|
return { probed: true, writable: true };
|
|
@@ -230,19 +291,49 @@ export async function preflightPatchWritable({
|
|
|
230
291
|
}
|
|
231
292
|
}
|
|
232
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Resolve the PATCH target, then run the same probe the real write will use.
|
|
296
|
+
* Callers inherit #166 targeting by going through this before dryRun or write.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} backend
|
|
299
|
+
* @param {{entityType: string, bundle: string, id: string, existing?: ?object, attributes?: object}} args
|
|
300
|
+
* @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
|
|
301
|
+
*/
|
|
302
|
+
export async function prepareGuardedPatch(backend, {
|
|
303
|
+
entityType, bundle, id, existing, attributes,
|
|
304
|
+
}) {
|
|
305
|
+
const target = shouldPreflightPatch({ existing, attributes })
|
|
306
|
+
? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
|
|
307
|
+
: { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
|
|
308
|
+
await preflightPatchWritable({
|
|
309
|
+
backend, entityType, bundle, id, existing, attributes,
|
|
310
|
+
resourceVersion: target.resourceVersion,
|
|
311
|
+
});
|
|
312
|
+
return target;
|
|
313
|
+
}
|
|
314
|
+
|
|
233
315
|
/**
|
|
234
316
|
* `backend.updateEntity` with the core working-copy 400 rewritten.
|
|
317
|
+
* A working-copy-targeted 400 is stale/concurrent (#166). A canonical 400
|
|
318
|
+
* with no addressable working copy is the #201 stray-revision case.
|
|
235
319
|
* @param {object} backend
|
|
236
320
|
* @param {object} input updateEntity argument.
|
|
237
321
|
* @returns {Promise<*>}
|
|
238
|
-
* @throws {PatchBlockedError|*}
|
|
322
|
+
* @throws {PatchBlockedError|WorkingCopyStaleError|*}
|
|
239
323
|
*/
|
|
240
324
|
export async function updateEntityGuarded(backend, input) {
|
|
241
325
|
try {
|
|
242
326
|
return await backend.updateEntity(input);
|
|
243
327
|
} catch (err) {
|
|
244
328
|
if (!isWorkingCopyPatchError(err)) throw err;
|
|
329
|
+
const cause = err instanceof Error ? err : new Error(String(err));
|
|
330
|
+
if (input?.resourceVersion === "rel:working-copy") {
|
|
331
|
+
throw new WorkingCopyStaleError(cause);
|
|
332
|
+
}
|
|
245
333
|
const workingCopy = await loadWorkingCopy(backend, input);
|
|
246
|
-
|
|
334
|
+
if (workingCopy) {
|
|
335
|
+
throw new WorkingCopyStaleError(cause);
|
|
336
|
+
}
|
|
337
|
+
throw new PatchBlockedError(cause);
|
|
247
338
|
}
|
|
248
339
|
}
|
package/src/lib/security.js
CHANGED
|
@@ -53,6 +53,17 @@ import { parse } from "graphql";
|
|
|
53
53
|
*
|
|
54
54
|
* globalRedactedFields string[] stripped from every response, every type
|
|
55
55
|
*
|
|
56
|
+
* declaredCeiling string narrow-only X-MCP-Declared-Ceiling
|
|
57
|
+
* (public|internal|restricted). Invalid
|
|
58
|
+
* values are dropped, never widened.
|
|
59
|
+
* readBudgets object finite northbound budgets bound to
|
|
60
|
+
* principal+target (#179). Same classes
|
|
61
|
+
* as mcp_sentinel: results, bytes,
|
|
62
|
+
* requests, requestWindowSec, pages,
|
|
63
|
+
* pageWindowSec, chainedActions,
|
|
64
|
+
* chainedActionWindowSec. Omitted keys
|
|
65
|
+
* use the module defaults.
|
|
66
|
+
*
|
|
56
67
|
* ─── Field redaction ──────────────────────────────────────────────────────
|
|
57
68
|
*
|
|
58
69
|
* Redacted fields are replaced with "[REDACTED]" in response attributes.
|
|
@@ -259,6 +270,8 @@ export function resolveSecurityConfig(site) {
|
|
|
259
270
|
...(preset.globalRedactedFields ?? []),
|
|
260
271
|
...(raw.globalRedactedFields ?? []),
|
|
261
272
|
],
|
|
273
|
+
declaredCeiling: raw.declaredCeiling ?? preset.declaredCeiling,
|
|
274
|
+
readBudgets: raw.readBudgets ?? preset.readBudgets ?? null,
|
|
262
275
|
};
|
|
263
276
|
}
|
|
264
277
|
|
|
@@ -470,7 +483,7 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
|
|
|
470
483
|
* @param {string} query GraphQL document text.
|
|
471
484
|
* @returns {boolean} True if any operation is a mutation.
|
|
472
485
|
*/
|
|
473
|
-
function graphqlHasMutation(query) {
|
|
486
|
+
export function graphqlHasMutation(query) {
|
|
474
487
|
try {
|
|
475
488
|
const doc = parse(query);
|
|
476
489
|
return doc.definitions.some(
|
|
@@ -754,5 +767,6 @@ export function getSecuritySummary(site) {
|
|
|
754
767
|
deniedEntityTypes: cfg.deniedEntityTypes,
|
|
755
768
|
entityRules: cfg.entityRules,
|
|
756
769
|
globalRedactedFields: cfg.globalRedactedFields,
|
|
770
|
+
declaredCeiling: cfg.declaredCeiling ?? null,
|
|
757
771
|
};
|
|
758
772
|
}
|
package/src/lib/server-tools.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import fetch from "node-fetch";
|
|
26
26
|
import { authHeadersAsync, clientHeaders, CLIENT_VERSION } from "./config.js";
|
|
27
|
+
import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial } from "./data-flow.js";
|
|
27
28
|
import { clearToken } from "./oauth.js";
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -90,6 +91,7 @@ async function baseHeaders(site, sessionId) {
|
|
|
90
91
|
Accept: "application/json, text/event-stream",
|
|
91
92
|
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
92
93
|
...clientHeaders(),
|
|
94
|
+
...northboundHeaders(),
|
|
93
95
|
...(await authHeadersAsync(site)),
|
|
94
96
|
};
|
|
95
97
|
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
@@ -263,9 +265,12 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
263
265
|
let sessionId = await ensureSession(site, endpoint);
|
|
264
266
|
let refreshedAuth = false;
|
|
265
267
|
let reinitedSession = false;
|
|
268
|
+
let paid = false;
|
|
266
269
|
|
|
267
270
|
// Retry loop: at most one auth refresh and one session re-init, each replayed once.
|
|
268
271
|
while (true) {
|
|
272
|
+
consumeBudgetIfEnforced("request", 1, { retry: paid });
|
|
273
|
+
paid = true;
|
|
269
274
|
const res = await fetch(endpoint, {
|
|
270
275
|
method: "POST",
|
|
271
276
|
headers: await baseHeaders(site, sessionId),
|
|
@@ -289,6 +294,8 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
289
294
|
}
|
|
290
295
|
|
|
291
296
|
if (!res.ok) {
|
|
297
|
+
const mapped = sourceBudgetDenial(rawText);
|
|
298
|
+
if (mapped) throw mapped;
|
|
292
299
|
throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${rawText}`);
|
|
293
300
|
}
|
|
294
301
|
|
|
@@ -303,6 +310,8 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
303
310
|
const result = body?.result;
|
|
304
311
|
if (result?.isError) {
|
|
305
312
|
const detail = extractTextContent(result) || "tool reported an error";
|
|
313
|
+
const mapped = sourceBudgetDenial(detail);
|
|
314
|
+
if (mapped) throw mapped;
|
|
306
315
|
throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
|
|
307
316
|
}
|
|
308
317
|
return result;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved-target disclosure and the multi-site write guard (#167).
|
|
3
|
+
*
|
|
4
|
+
* A silent default that returns plausible content from the wrong environment
|
|
5
|
+
* is the failure class: the response looks like success and every field a
|
|
6
|
+
* caller would sanity-check (id, title, url) is correct. `_target` uses the
|
|
7
|
+
* same `{ name, baseUrl, source }` block as `drupal_mcp_whoami` so there is
|
|
8
|
+
* one vocabulary. `source` is load-bearing — `hint` when the caller named a
|
|
9
|
+
* target, `default` when `defaultSite` was used, `grant` when a principal
|
|
10
|
+
* had exactly one entitled site.
|
|
11
|
+
*
|
|
12
|
+
* Reads may still default. Writes (and GraphQL mutations) may not when more
|
|
13
|
+
* than one site is configured: a write on the wrong site is not recoverable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describeTarget } from "./principal.js";
|
|
17
|
+
import { isWriteLikeCall } from "./operations.js";
|
|
18
|
+
import { SecurityError } from "./security.js";
|
|
19
|
+
|
|
20
|
+
/** Shared `site` argument schema. Injected onto every tool that accepts `site`. */
|
|
21
|
+
export const SITE_PARAM = {
|
|
22
|
+
type: "string",
|
|
23
|
+
description:
|
|
24
|
+
"Named site from connector config. Omit only on reads: multi-site configs " +
|
|
25
|
+
"fall back to defaultSite (often local/dev, not production). Writes require " +
|
|
26
|
+
"an explicit site when more than one site is configured. Every response " +
|
|
27
|
+
"includes `_target` { name, baseUrl, source } (`hint` when you passed site, " +
|
|
28
|
+
"`default` when you did not).",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Attach the resolved target to a tool payload so JSON.stringify keeps it.
|
|
33
|
+
*
|
|
34
|
+
* Object results get `_target` as a sibling. Arrays are wrapped as
|
|
35
|
+
* `{ items, _target }` because extra properties on an array are dropped by
|
|
36
|
+
* JSON.stringify. Tools that do not address a single site pass `resolved` as
|
|
37
|
+
* null and are returned unchanged.
|
|
38
|
+
*
|
|
39
|
+
* @param {*} result Handler return value.
|
|
40
|
+
* @param {?{site: object, source: string}} resolved
|
|
41
|
+
* @returns {*}
|
|
42
|
+
*/
|
|
43
|
+
export function withResolvedTarget(result, resolved) {
|
|
44
|
+
if (!resolved?.site) return result;
|
|
45
|
+
const _target = describeTarget(resolved.site, resolved.source);
|
|
46
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
47
|
+
return { ...result, _target };
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(result)) {
|
|
50
|
+
return { items: result, _target };
|
|
51
|
+
}
|
|
52
|
+
if (result === undefined || result === null) {
|
|
53
|
+
return { _target };
|
|
54
|
+
}
|
|
55
|
+
return { result, _target };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Refuse a write that would silently land on defaultSite when more than one
|
|
60
|
+
* site is configured. Resolution behaviour for reads is unchanged.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} toolName
|
|
63
|
+
* @param {object} args Raw caller arguments (before any default rewrite).
|
|
64
|
+
* @param {?{site: object, source: string, name: string}} resolved
|
|
65
|
+
* @param {string[]} siteNames Configured site names.
|
|
66
|
+
* @returns {void}
|
|
67
|
+
* @throws {SecurityError}
|
|
68
|
+
*/
|
|
69
|
+
export function assertExplicitSiteForWrite(toolName, args, resolved, siteNames) {
|
|
70
|
+
if (!resolved || resolved.source !== "default") return;
|
|
71
|
+
if (!Array.isArray(siteNames) || siteNames.length < 2) return;
|
|
72
|
+
if (!isWriteLikeCall(toolName, args)) return;
|
|
73
|
+
throw new SecurityError(
|
|
74
|
+
"Write tools require an explicit site when more than one site is configured. " +
|
|
75
|
+
"Omitted site would default to \"" + resolved.name + "\" (" + resolved.site.baseUrl + "). " +
|
|
76
|
+
"Pass site explicitly. Configured sites: " + siteNames.join(", ") + ".",
|
|
77
|
+
);
|
|
78
|
+
}
|
package/src/lib/tool-prompts.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { isDestructiveTool } from "./operations.js";
|
|
18
|
+
import { SITE_PARAM } from "./site-target.js";
|
|
18
19
|
|
|
19
20
|
/** Convert a tool name to its prompt/command name: `drupal_create_node` → `drupal-create-node`. */
|
|
20
21
|
export const toolNameToPromptName = (name) => name.replace(/_/g, "-");
|
|
@@ -54,7 +55,7 @@ export function paramList(inputSchema) {
|
|
|
54
55
|
name,
|
|
55
56
|
required: required.has(name),
|
|
56
57
|
hint: typeHint(spec),
|
|
57
|
-
description: spec?.description || (name === "site" ?
|
|
58
|
+
description: spec?.description || (name === "site" ? SITE_PARAM.description : ""),
|
|
58
59
|
}));
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -5,7 +5,79 @@
|
|
|
5
5
|
* published node — after a draft ERR attach it still shows the *old* refs.
|
|
6
6
|
* Prefer `rel:working-copy`. If that alias is not addressable, return the
|
|
7
7
|
* PATCH body (or the canonical re-read) plus `_revision.relationshipsUnverified`.
|
|
8
|
+
*
|
|
9
|
+
* After a write, {@link attachWrittenRevisionPair} may add `_revisions`
|
|
10
|
+
* `{ live, working }` when both vids can be read honestly (#166).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read a revision id off a canonical (or raw-ish) entity body.
|
|
15
|
+
* @param {?object} entity
|
|
16
|
+
* @returns {?number|string}
|
|
17
|
+
*/
|
|
18
|
+
export function entityRevisionId(entity) {
|
|
19
|
+
if (!entity || typeof entity !== "object") return null;
|
|
20
|
+
const fields = entity.fields && typeof entity.fields === "object"
|
|
21
|
+
? entity.fields
|
|
22
|
+
: {};
|
|
23
|
+
const attrs = entity.attributes && typeof entity.attributes === "object"
|
|
24
|
+
? entity.attributes
|
|
25
|
+
: {};
|
|
26
|
+
const raw = entity.vid
|
|
27
|
+
?? fields.drupal_internal__vid
|
|
28
|
+
?? attrs.drupal_internal__vid
|
|
29
|
+
?? entity.drupal_internal__vid;
|
|
30
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
31
|
+
const n = Number(raw);
|
|
32
|
+
return Number.isFinite(n) ? n : raw;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Attach distinct live vs working revision ids when both are known.
|
|
37
|
+
* Does not invent vids.
|
|
38
|
+
* @param {object} entity
|
|
39
|
+
* @param {{live: ?number|string, working: ?number|string}} pair
|
|
40
|
+
* @returns {object}
|
|
8
41
|
*/
|
|
42
|
+
export function attachRevisionPair(entity, { live, working }) {
|
|
43
|
+
if (!entity || live === null || live === undefined || working === null || working === undefined) {
|
|
44
|
+
return entity;
|
|
45
|
+
}
|
|
46
|
+
if (String(live) === String(working)) return entity;
|
|
47
|
+
return { ...entity, _revisions: { live, working } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* After a write, attach `_revisions` when a working copy is addressable
|
|
52
|
+
* and the live vid is already known. Omits the block when either side
|
|
53
|
+
* cannot be read — never invents a vid.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} args
|
|
56
|
+
* @param {object} args.backend
|
|
57
|
+
* @param {string} args.entityType
|
|
58
|
+
* @param {string} args.bundle
|
|
59
|
+
* @param {string} args.id
|
|
60
|
+
* @param {object} args.entity Write result to annotate.
|
|
61
|
+
* @param {?number|string} [args.liveVid]
|
|
62
|
+
* @returns {Promise<object>}
|
|
63
|
+
*/
|
|
64
|
+
export async function attachWrittenRevisionPair({
|
|
65
|
+
backend, entityType, bundle, id, entity, liveVid,
|
|
66
|
+
}) {
|
|
67
|
+
if (liveVid === null || liveVid === undefined || !entity) return entity;
|
|
68
|
+
let workingVid = null;
|
|
69
|
+
if (typeof backend?.getEntity === "function") {
|
|
70
|
+
try {
|
|
71
|
+
const wc = await backend.getEntity({
|
|
72
|
+
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
73
|
+
});
|
|
74
|
+
workingVid = entityRevisionId(wc);
|
|
75
|
+
} catch {
|
|
76
|
+
workingVid = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return attachRevisionPair(entity, { live: liveVid, working: workingVid });
|
|
80
|
+
}
|
|
9
81
|
|
|
10
82
|
/**
|
|
11
83
|
* @param {object} args
|
|
@@ -16,15 +88,23 @@
|
|
|
16
88
|
* @param {boolean} args.relationshipsSent
|
|
17
89
|
* @param {?object} [args.patchResult] Canonicalised PATCH response body.
|
|
18
90
|
* @param {boolean} [args.preferCanonical] When no relationships were sent,
|
|
19
|
-
* re-GET the
|
|
91
|
+
* re-GET the written resource (nodes do this for the persisted alias).
|
|
92
|
+
* @param {?string} [args.resourceVersion] JSON:API revision selector used
|
|
93
|
+
* for the write (e.g. `rel:working-copy`). PreferCanonical re-reads this
|
|
94
|
+
* resource instead of the default revision so a draft PATCH is not
|
|
95
|
+
* replaced by the live body.
|
|
20
96
|
* @returns {Promise<object>} Entity to return, with `_revision` when relevant.
|
|
21
97
|
*/
|
|
22
98
|
export async function readWrittenRevision({
|
|
23
99
|
backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
|
|
100
|
+
resourceVersion,
|
|
24
101
|
}) {
|
|
25
102
|
if (!relationshipsSent) {
|
|
26
103
|
if (preferCanonical && typeof backend.getEntity === "function") {
|
|
27
|
-
const fresh = await backend.getEntity({
|
|
104
|
+
const fresh = await backend.getEntity({
|
|
105
|
+
entityType, bundle, id,
|
|
106
|
+
...(resourceVersion ? { resourceVersion } : {}),
|
|
107
|
+
}).catch(() => null);
|
|
28
108
|
return fresh ?? patchResult ?? { id };
|
|
29
109
|
}
|
|
30
110
|
return patchResult ?? { id };
|
package/src/tools/bulk.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
resolveErrRelationships, embedParagraphRef,
|
|
20
20
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
21
21
|
} from "../lib/err-relationships.js";
|
|
22
|
-
import {
|
|
22
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Normalize an unknown thrown value into a human-readable message.
|
|
@@ -118,13 +118,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
118
118
|
});
|
|
119
119
|
assertPublishAllowed(sec, attributes);
|
|
120
120
|
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
121
|
-
await
|
|
122
|
-
|
|
121
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
122
|
+
entityType, bundle, id: item.id, existing, attributes,
|
|
123
123
|
});
|
|
124
124
|
const entity = await updateEntityGuarded(backend, {
|
|
125
125
|
entityType, bundle, id: item.id,
|
|
126
126
|
attributes,
|
|
127
127
|
relationships: resolvedRelationships,
|
|
128
|
+
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
128
129
|
});
|
|
129
130
|
updated += 1;
|
|
130
131
|
results.push({ index, success: true, id: entity?.id ?? item.id });
|