drupal-mcp-connector 3.2.0 → 3.2.2
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/CHANGELOG.md +35 -0
- package/README.md +21 -2
- package/package.json +1 -1
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +4 -1
- package/src/lib/canonical-draft.js +72 -6
- package/src/lib/component-draft.js +8 -2
- package/src/lib/dispatch.js +7 -0
- package/src/lib/field-definition.js +146 -41
- package/src/lib/nested-draft.js +20 -4
- package/src/lib/text-format-context.js +248 -0
- package/src/tools/bulk.js +12 -3
- package/src/tools/entities.js +11 -3
- package/src/tools/media.js +9 -2
- package/src/tools/nested.js +1 -0
- package/src/tools/nodes.js +1 -1
- package/src/tools/paragraphs.js +12 -3
- package/src/tools/translations.js +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.2.2] - 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Library draft paragraph revisions (#420).** Entity Reference Revisions
|
|
14
|
+
saves a new paragraph revision when an existing host saves a new revision.
|
|
15
|
+
A library or custom-block draft matches when it pins that same paragraph,
|
|
16
|
+
in order, at the submitted revision or a newer one. An older revision, a
|
|
17
|
+
different paragraph, or a different order still fails. The published
|
|
18
|
+
revision's pins must still match exactly.
|
|
19
|
+
|
|
20
|
+
## [3.2.1] - 2026-10-07
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Text formats from Sentinel context (#429).** Node writes read
|
|
24
|
+
`allowed_formats` from `GET /drupal-mcp/context` when JSON:API field
|
|
25
|
+
config is not readable. A present list is enforced, including a
|
|
26
|
+
one-element `{ value, format }` array. A missing key stays an unknown
|
|
27
|
+
list, so an older Sentinel still passes an explicit format through.
|
|
28
|
+
Entity, translation, bulk, paragraph, and media writes use the same
|
|
29
|
+
check and do not invent `full_html` for a string body. A JSON:API row
|
|
30
|
+
whose field name does not match the request is ignored. A context miss
|
|
31
|
+
is remembered for 30 seconds, and only tools that resolve text formats
|
|
32
|
+
wait on that document. Component drafts and nested paragraph children,
|
|
33
|
+
including their translations, are checked before preflight or create.
|
|
34
|
+
An update that sends a one-element text array without a format reuses
|
|
35
|
+
the stored format. Hosts stay on the previous behavior until Sentinel
|
|
36
|
+
2.32.0, which emits the key, is deployed.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- **Consumers pin a version and check advisories before upgrade (#423).**
|
|
40
|
+
The README tells operators to pin an exact version, confirm the registry
|
|
41
|
+
integrity checksum, read the changelog, and run `npm audit --omit=dev`
|
|
42
|
+
before upgrading. No separate scanner. The install and `npx` examples,
|
|
43
|
+
and the post-upgrade doctor command, name that same version.
|
|
44
|
+
|
|
10
45
|
## [3.2.0] - 2026-10-05
|
|
11
46
|
|
|
12
47
|
### Added
|
package/README.md
CHANGED
|
@@ -278,8 +278,8 @@ the allowlist or Sentinel profile excludes the type — run `doctor` and
|
|
|
278
278
|
### From npm (recommended for operators)
|
|
279
279
|
|
|
280
280
|
```bash
|
|
281
|
-
npm install -g drupal-mcp-connector
|
|
282
|
-
# or: npx -y drupal-mcp-connector
|
|
281
|
+
npm install -g drupal-mcp-connector@3.2.2
|
|
282
|
+
# or: npx -y drupal-mcp-connector@3.2.2
|
|
283
283
|
```
|
|
284
284
|
|
|
285
285
|
Point your MCP client at the installed binary (path from `which drupal-mcp-connector`
|
|
@@ -287,6 +287,25 @@ or `node_modules/.bin/drupal-mcp-connector`). For multi-site or non-env config,
|
|
|
287
287
|
clone the repo (or copy `config/config.example.json` beside a small launcher) and
|
|
288
288
|
pass a config path — see **[docs/getting-started.md](docs/getting-started.md)**.
|
|
289
289
|
|
|
290
|
+
### Before you upgrade
|
|
291
|
+
|
|
292
|
+
Pin the version you run. Do not point a client you depend on at `latest` or an
|
|
293
|
+
unbounded range.
|
|
294
|
+
|
|
295
|
+
1. Pin an exact version in the lockfile (`package-lock.json` or
|
|
296
|
+
`npm-shrinkwrap.json`), or in `package.json` as
|
|
297
|
+
`"drupal-mcp-connector": "3.2.2"` (use the version you intend to run).
|
|
298
|
+
2. Confirm the registry checksum before you install.
|
|
299
|
+
`npm view drupal-mcp-connector@3.2.2 dist.integrity` prints the integrity
|
|
300
|
+
npm checks. A lockfile install fails when the tarball does not match.
|
|
301
|
+
3. Read [CHANGELOG.md](CHANGELOG.md) for that version.
|
|
302
|
+
4. Run `npm audit --omit=dev` against the lockfile and read any advisory
|
|
303
|
+
before you upgrade. There is no separate scanner to install.
|
|
304
|
+
|
|
305
|
+
Restart the MCP client after the upgrade. Run `drupal-mcp-doctor` when the
|
|
306
|
+
package is on your PATH, or `npx -y drupal-mcp-connector@3.2.2 doctor` when
|
|
307
|
+
you launch it with npx.
|
|
308
|
+
|
|
290
309
|
### From a git clone (development)
|
|
291
310
|
|
|
292
311
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"description": "Multi-site MCP server for Drupal over JSON:API and GraphQL, with governed writes and an SSH Drush bridge. init writes a stdio or Tailscale client config from a transport preset. doctor checks reachability, auth, and readiness, and reports version skew. A timestamp gap on a published revision no longer blocks the next draft.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-
|
|
|
50
50
|
import { createConnectorServerFactory } from "./lib/mcp-server.js";
|
|
51
51
|
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
52
52
|
import { callTool, invokeReadTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
|
|
53
|
+
import { fetchTextFormatContext, installTextFormatContextFetcher } from "./lib/text-format-context.js";
|
|
53
54
|
import { filterDiscoverableTools } from "./lib/governance.js";
|
|
54
55
|
import {
|
|
55
56
|
filterPromptsByPrincipal,
|
|
@@ -75,6 +76,8 @@ import { builtinWorkflowProvider } from "./lib/workflows/builtin.js";
|
|
|
75
76
|
import { runWizardCli } from "./lib/wizard.js";
|
|
76
77
|
import { runDoctorCli } from "./lib/doctor.js";
|
|
77
78
|
|
|
79
|
+
installTextFormatContextFetcher(fetchTextFormatContext);
|
|
80
|
+
|
|
78
81
|
const cliCommand = process.argv[2];
|
|
79
82
|
if (cliCommand === "wizard" || cliCommand === "init") {
|
|
80
83
|
process.exit(await runWizardCli(process.argv.slice(3)));
|
|
@@ -884,7 +884,10 @@ export class JsonApiBackend extends Backend {
|
|
|
884
884
|
try {
|
|
885
885
|
const data = await drupalFetch(this.site, `/jsonapi/${resource}/${resource}?${params}`);
|
|
886
886
|
const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
|
|
887
|
-
return
|
|
887
|
+
// A filter that is ignored would return some other field. Using that
|
|
888
|
+
// row's allowed_formats would persist the wrong format (#429).
|
|
889
|
+
if (row?.attributes?.field_name !== fieldName) return null;
|
|
890
|
+
return parseFieldConfigObject(row.attributes, fieldName);
|
|
888
891
|
} catch {
|
|
889
892
|
return null;
|
|
890
893
|
}
|
|
@@ -23,10 +23,14 @@
|
|
|
23
23
|
* timeout, or a missing revision id fails closed. A draft-inventory 404 or
|
|
24
24
|
* 405 is absence. Any other inventory failure fails closed. After the write
|
|
25
25
|
* the published revision, rel:working-copy, and the published paragraph pins
|
|
26
|
-
* (including order) are re-read.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* (including order) are re-read. Entity Reference Revisions saves a new
|
|
27
|
+
* paragraph revision when an existing host saves a new revision, so the draft
|
|
28
|
+
* may pin a later revision of the submitted paragraph. That later revision
|
|
29
|
+
* matches. An older revision, a different paragraph, or a different order
|
|
30
|
+
* does not. A completed 4xx is returned when that re-read matches the
|
|
31
|
+
* pre-write state. A lost response, or a 4xx whose re-read shows a change,
|
|
32
|
+
* is reported from the re-read and is not called a rollback. An unmoderated
|
|
33
|
+
* entity keeps the ordinary update.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
import { entityRevisionId } from "./write-revision.js";
|
|
@@ -613,7 +617,7 @@ function assertPublishedPreserved(entityType, before, after, relationships) {
|
|
|
613
617
|
const baseline = continuing ? normalizedPins(before.working) : before.pins;
|
|
614
618
|
if (submitted) {
|
|
615
619
|
const landed = forwardPins.filter((pin) => submitted.fields.has(pin.field));
|
|
616
|
-
if (
|
|
620
|
+
if (!submittedPinsLanded(submitted.pins, landed)) {
|
|
617
621
|
problems.push("the forward revision does not pin the submitted paragraphs");
|
|
618
622
|
}
|
|
619
623
|
} else if (pinKey(forwardPins) !== pinKey(baseline)) {
|
|
@@ -633,7 +637,7 @@ function assertSubmittedPins(entityType, latest, relationships) {
|
|
|
633
637
|
const submitted = submittedParagraphPins(relationships);
|
|
634
638
|
if (!submitted) return;
|
|
635
639
|
const landed = normalizedPins(latest).filter((pin) => submitted.fields.has(pin.field));
|
|
636
|
-
if (
|
|
640
|
+
if (!submittedPinsLanded(submitted.pins, landed)) {
|
|
637
641
|
throw uncertainWriteError(entityType, "the written revision does not pin the submitted paragraphs");
|
|
638
642
|
}
|
|
639
643
|
}
|
|
@@ -793,3 +797,65 @@ function pinKey(pins) {
|
|
|
793
797
|
return `${field}\0${rows.join("\n")}`;
|
|
794
798
|
}).join("\n\n");
|
|
795
799
|
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Group pins by field, preserving submission and re-read order inside a field.
|
|
803
|
+
* @param {object[]} pins
|
|
804
|
+
* @returns {Map<string, object[]>}
|
|
805
|
+
*/
|
|
806
|
+
function pinsByField(pins) {
|
|
807
|
+
const groups = new Map();
|
|
808
|
+
for (const pin of pins) {
|
|
809
|
+
if (!groups.has(pin.field)) groups.set(pin.field, []);
|
|
810
|
+
groups.get(pin.field).push(pin);
|
|
811
|
+
}
|
|
812
|
+
return groups;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Entity Reference Revisions creates a new paragraph revision when an existing
|
|
817
|
+
* host saves a new revision, then points the host at that revision. The
|
|
818
|
+
* submitted id is the earlier one. A missing revision on either side matches
|
|
819
|
+
* only another missing revision.
|
|
820
|
+
* @param {?string} submittedId
|
|
821
|
+
* @param {?string} landedId
|
|
822
|
+
* @returns {boolean}
|
|
823
|
+
*/
|
|
824
|
+
function sameRevisionOrNewer(submittedId, landedId) {
|
|
825
|
+
const submittedMissing = submittedId === null || submittedId === undefined || submittedId === "";
|
|
826
|
+
const landedMissing = landedId === null || landedId === undefined || landedId === "";
|
|
827
|
+
if (submittedMissing || landedMissing) return submittedMissing && landedMissing;
|
|
828
|
+
const submitted = Number(submittedId);
|
|
829
|
+
const landed = Number(landedId);
|
|
830
|
+
if (!Number.isSafeInteger(submitted) || !Number.isSafeInteger(landed)) {
|
|
831
|
+
return String(submittedId) === String(landedId);
|
|
832
|
+
}
|
|
833
|
+
return landed >= submitted;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Whether the re-read pins the submitted paragraphs, in order. A later
|
|
838
|
+
* revision of the same paragraph counts. An older revision does not.
|
|
839
|
+
* @param {object[]} submittedPins
|
|
840
|
+
* @param {object[]} landedPins
|
|
841
|
+
* @returns {boolean}
|
|
842
|
+
*/
|
|
843
|
+
function submittedPinsLanded(submittedPins, landedPins) {
|
|
844
|
+
const submitted = pinsByField(submittedPins);
|
|
845
|
+
const landed = pinsByField(landedPins);
|
|
846
|
+
const submittedFields = [...submitted.keys()].sort();
|
|
847
|
+
const landedFields = [...landed.keys()].sort();
|
|
848
|
+
if (submittedFields.length !== landedFields.length
|
|
849
|
+
|| submittedFields.some((field, index) => field !== landedFields[index])) {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
return submittedFields.every((field) => {
|
|
853
|
+
const left = submitted.get(field) ?? [];
|
|
854
|
+
const right = landed.get(field) ?? [];
|
|
855
|
+
if (left.length !== right.length) return false;
|
|
856
|
+
return left.every((pin, index) => {
|
|
857
|
+
const got = right[index];
|
|
858
|
+
return got.id === pin.id && sameRevisionOrNewer(pin.revisionId, got.revisionId);
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readNodeDraftInventory, assertDraftLangcode } from "./sentinel-draft.js";
|
|
17
17
|
import { entityRevisionId } from "./write-revision.js";
|
|
18
18
|
import { paragraphPinsFromEntity } from "./err-relationships.js";
|
|
19
|
+
import { applyParagraphTextFormats } from "./field-definition.js";
|
|
19
20
|
import { PREFLIGHT_SENTINEL_DRAFT, dryRunChecks } from "./dry-run-checks.js";
|
|
20
21
|
|
|
21
22
|
/** Inventory operation: component paragraph changes inside a draft save. */
|
|
@@ -159,14 +160,19 @@ function pinMap(entity) {
|
|
|
159
160
|
* @param {object} [input.relationships]
|
|
160
161
|
* @param {Array} input.components Entries for {@link normalizeComponents}.
|
|
161
162
|
* @param {string} [input.langcode] Must be the default language when given.
|
|
162
|
-
* @param {{dryRun?: boolean}} [options]
|
|
163
|
+
* @param {{dryRun?: boolean, site?: object}} [options]
|
|
163
164
|
* @returns {Promise<object>} Preview on dryRun; otherwise the written entity
|
|
164
165
|
* plus `_components` (live and working pins and verified fields per
|
|
165
166
|
* paragraph).
|
|
166
167
|
*/
|
|
167
|
-
export async function writeComponentDraft(backend, input, { dryRun = false } = {}) {
|
|
168
|
+
export async function writeComponentDraft(backend, input, { dryRun = false, site = {} } = {}) {
|
|
168
169
|
const { entityType, bundle, id, attributes = {}, relationships } = input;
|
|
169
170
|
const components = normalizeComponents(input.components);
|
|
171
|
+
// Refuse a disallowed paragraph format before inventory or the draft preflight.
|
|
172
|
+
for (const component of components) {
|
|
173
|
+
const paragraphBundle = component.type.slice("paragraph--".length);
|
|
174
|
+
await applyParagraphTextFormats(backend, site, paragraphBundle, component.attributes);
|
|
175
|
+
}
|
|
170
176
|
if (Object.hasOwn(attributes, "path")) {
|
|
171
177
|
throw new Error("Aliases are not revisioned. Change the alias in a separate update; no write was attempted.");
|
|
172
178
|
}
|
package/src/lib/dispatch.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveSecurityConfig, assertNotReadOnly,
|
|
|
15
15
|
import { toolError, toolResult } from "./errors.js";
|
|
16
16
|
import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
|
|
17
17
|
import { inferOperation } from "./operations.js";
|
|
18
|
+
import { ensureTextFormatContext, toolNeedsTextFormatContext } from "./text-format-context.js";
|
|
18
19
|
import { assertSourceGovernance, GovernanceError } from "./governance.js";
|
|
19
20
|
import {
|
|
20
21
|
assertPrincipalEntitlement, callerTargetHints, DIAGNOSTIC_TOOLS,
|
|
@@ -186,6 +187,12 @@ export async function securityMiddleware(toolName, args, handler, context = {})
|
|
|
186
187
|
});
|
|
187
188
|
return runWithDataFlow(flow, async () => {
|
|
188
189
|
await assertCallAllowed();
|
|
190
|
+
// Content-tier tokens cannot read field_config. Load Sentinel's schema
|
|
191
|
+
// before a handler that resolves text formats (#429). Other writes do
|
|
192
|
+
// not wait. A miss leaves the historical unknown-list path in place.
|
|
193
|
+
if (toolNeedsTextFormatContext(toolName)) {
|
|
194
|
+
await ensureTextFormatContext(site);
|
|
195
|
+
}
|
|
189
196
|
// Charge only after governance and policy gates pass, so an outage or a
|
|
190
197
|
// local deny cannot exhaust the window. Diagnostics do not consume.
|
|
191
198
|
if (!DIAGNOSTIC_TOOLS.has(toolName)) {
|
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
* `base_field_override` (node body is a base field; its allowed_formats
|
|
12
12
|
* are not on `field_config`). Internal adapter fetch, not
|
|
13
13
|
* `drupal_entity_get` (`field_config` is on the agent deny list).
|
|
14
|
-
*
|
|
14
|
+
* A row whose `field_name` is not the requested field is a miss.
|
|
15
|
+
* 2. Cached Sentinel `GET /drupal-mcp/context`, for node bundles, when the
|
|
16
|
+
* field entry includes `allowed_formats` (#429). A content-tier token
|
|
17
|
+
* cannot read `field_config`. Dispatch fills this cache before a tool
|
|
18
|
+
* that resolves text formats.
|
|
19
|
+
* 3. Drush `config:get field.field.{entityType}.{bundle}.{field}`, then
|
|
15
20
|
* `core.base_field_override.{entityType}.{bundle}.{field}`, when a Drush
|
|
16
21
|
* bridge is configured.
|
|
17
|
-
*
|
|
22
|
+
* 4. If the definition cannot be resolved: return null. Creates keep the
|
|
18
23
|
* historical default chain only while the list is unknown. Updates reuse
|
|
19
24
|
* the format already stored on that field (#327) so a dry run cannot
|
|
20
25
|
* preview a fallback the save will reject. Once the list is known, never
|
|
@@ -22,6 +27,7 @@
|
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import { drushConfigured } from "./audit-sources.js";
|
|
30
|
+
import { textFormatDefinitionFromContext } from "./text-format-context.js";
|
|
25
31
|
import { validateMachineName } from "./validate.js";
|
|
26
32
|
import { parseDrush, sshDrush } from "../tools/drush.js";
|
|
27
33
|
|
|
@@ -137,15 +143,19 @@ export async function resolveFieldDefinition(backend, site, entityType, bundle,
|
|
|
137
143
|
if (typeof backend?.getFieldDefinition === "function") {
|
|
138
144
|
try {
|
|
139
145
|
const def = await backend.getFieldDefinition({ entityType, bundle, fieldName });
|
|
140
|
-
if (def)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
if (def?.fieldName === fieldName) {
|
|
147
|
+
return parseFieldConfigObject({
|
|
148
|
+
field_name: def.fieldName,
|
|
149
|
+
field_type: def.fieldType,
|
|
150
|
+
settings: { allowed_formats: def.allowedFormats },
|
|
151
|
+
}, fieldName) ?? def;
|
|
152
|
+
}
|
|
145
153
|
} catch {
|
|
146
|
-
// JSON:API field_config is optional; try
|
|
154
|
+
// JSON:API field_config is optional; try the next source.
|
|
147
155
|
}
|
|
148
156
|
}
|
|
157
|
+
const fromContext = textFormatDefinitionFromContext(site, entityType, bundle, fieldName);
|
|
158
|
+
if (fromContext) return fromContext;
|
|
149
159
|
if (drushConfigured(site)) {
|
|
150
160
|
const def = await fieldDefinitionFromDrush(site, entityType, bundle, fieldName);
|
|
151
161
|
if (def) return def;
|
|
@@ -242,8 +252,19 @@ function requestedFormatOf(value) {
|
|
|
242
252
|
return undefined;
|
|
243
253
|
}
|
|
244
254
|
|
|
255
|
+
/**
|
|
256
|
+
* @param {*} value
|
|
257
|
+
* @returns {string|undefined}
|
|
258
|
+
*/
|
|
259
|
+
function storedFormatName(value) {
|
|
260
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
261
|
+
const format = new Map(Object.entries(value)).get("format");
|
|
262
|
+
return typeof format === "string" && format && format !== "0" ? format : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
245
265
|
/**
|
|
246
266
|
* Format Drupal already stored for a field, if the canonical entity has one.
|
|
267
|
+
* A one-element list is the JSON:API shape of a single-value text field.
|
|
247
268
|
* @param {?object} entity
|
|
248
269
|
* @param {string} fieldName
|
|
249
270
|
* @returns {string|undefined}
|
|
@@ -252,9 +273,31 @@ function storedTextFormat(entity, fieldName) {
|
|
|
252
273
|
const fields = entity?.fields;
|
|
253
274
|
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return undefined;
|
|
254
275
|
const value = new Map(Object.entries(fields)).get(fieldName);
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
if (value.length !== 1) return undefined;
|
|
278
|
+
return storedFormatName(value.at(0));
|
|
279
|
+
}
|
|
280
|
+
return storedFormatName(value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A one-element JSON:API list omits its format when the only item is a
|
|
285
|
+
* string or a `{ value }` object without `format`. A longer list of bare
|
|
286
|
+
* strings is a list field, not an omitted text format. A formatted item
|
|
287
|
+
* inside a longer list still counts.
|
|
288
|
+
* @param {unknown[]} value
|
|
289
|
+
* @returns {boolean}
|
|
290
|
+
*/
|
|
291
|
+
function arrayOmitsTextFormat(value) {
|
|
292
|
+
if (value.length === 1) {
|
|
293
|
+
const item = value.at(0);
|
|
294
|
+
if (typeof item === "string") return true;
|
|
295
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
|
|
296
|
+
const hasValue = Object.prototype.hasOwnProperty.call(item, "value");
|
|
297
|
+
const hasFormat = Object.prototype.hasOwnProperty.call(item, "format") && item.format;
|
|
298
|
+
return hasValue && !hasFormat;
|
|
299
|
+
}
|
|
300
|
+
return value.some((item) => isFormattedShape(item) && !requestedFormatOf(item));
|
|
258
301
|
}
|
|
259
302
|
|
|
260
303
|
/**
|
|
@@ -267,8 +310,12 @@ export function attributesOmitTextFormat(attributes) {
|
|
|
267
310
|
if (!attributes || typeof attributes !== "object") return false;
|
|
268
311
|
for (const [name, value] of Object.entries(attributes)) {
|
|
269
312
|
if (SKIP_FORMAT_FIELDS.has(name) || value === undefined || value === null) continue;
|
|
313
|
+
if (Array.isArray(value)) {
|
|
314
|
+
if (arrayOmitsTextFormat(value)) return true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
270
317
|
if (typeof value === "string") return true;
|
|
271
|
-
if (typeof value === "object"
|
|
318
|
+
if (typeof value === "object") {
|
|
272
319
|
const hasValue = Object.prototype.hasOwnProperty.call(value, "value");
|
|
273
320
|
const hasFormat = Object.prototype.hasOwnProperty.call(value, "format") && value.format;
|
|
274
321
|
if ((name === "body" || hasValue) && !hasFormat) return true;
|
|
@@ -294,6 +341,44 @@ function normalizeFormattedValue(raw, format) {
|
|
|
294
341
|
return out;
|
|
295
342
|
}
|
|
296
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Resolve one formatted value, or return it unchanged when it is not text.
|
|
346
|
+
* @param {string} fieldName
|
|
347
|
+
* @param {*} value
|
|
348
|
+
* @param {?object} def
|
|
349
|
+
* @param {boolean} treatAsBody
|
|
350
|
+
* @param {object} site
|
|
351
|
+
* @param {?object} existingEntity
|
|
352
|
+
* @returns {*}
|
|
353
|
+
*/
|
|
354
|
+
function resolveFormattedItem(fieldName, value, def, treatAsBody, site, existingEntity) {
|
|
355
|
+
const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
|
|
356
|
+
const restricted = Boolean(def?.allowedFormats?.length);
|
|
357
|
+
const shaped = isFormattedShape(value);
|
|
358
|
+
if (typeof value !== "string" && (typeof value !== "object" || value === null || Array.isArray(value))) {
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
if (def && !formattedType && !restricted && !treatAsBody) return value;
|
|
362
|
+
if (!def && !shaped && !treatAsBody) return value;
|
|
363
|
+
|
|
364
|
+
const allowedFormats = def ? def.allowedFormats : null;
|
|
365
|
+
const listUnknown = !Array.isArray(allowedFormats) || allowedFormats.length === 0;
|
|
366
|
+
let requested = requestedFormatOf(value);
|
|
367
|
+
if ((requested === undefined || requested === null || requested === "") && listUnknown) {
|
|
368
|
+
const stored = storedTextFormat(existingEntity, fieldName);
|
|
369
|
+
if (stored) requested = stored;
|
|
370
|
+
}
|
|
371
|
+
const format = resolveTextFormat({
|
|
372
|
+
fieldName,
|
|
373
|
+
requested,
|
|
374
|
+
allowedFormats,
|
|
375
|
+
site,
|
|
376
|
+
defaultWhenUnknown: treatAsBody,
|
|
377
|
+
});
|
|
378
|
+
if (format === undefined && !formattedType && !treatAsBody && !shaped) return value;
|
|
379
|
+
return normalizeFormattedValue(value, format);
|
|
380
|
+
}
|
|
381
|
+
|
|
297
382
|
/**
|
|
298
383
|
* Default and validate text formats on a write attribute map (mutates it).
|
|
299
384
|
* Same checks run for dry-run and real writes so a disallowed format never
|
|
@@ -303,46 +388,66 @@ function normalizeFormattedValue(raw, format) {
|
|
|
303
388
|
* caller omitted a format, the format already stored on that field is reused.
|
|
304
389
|
* The same choice is what dryRun previews and what the save sends (#327).
|
|
305
390
|
*
|
|
306
|
-
*
|
|
391
|
+
* `defaultBodyFormat` is true for the node tools, which keep the historical
|
|
392
|
+
* body fallback when the list is unknown. Other write tools pass false so a
|
|
393
|
+
* string body is not rewritten to `full_html`.
|
|
394
|
+
*
|
|
395
|
+
* A one-element array is checked too (#429). Callers sometimes send the
|
|
396
|
+
* JSON:API list shape for a single-value text field; skipping the array let
|
|
397
|
+
* a disallowed format through. An update that omits the format on that
|
|
398
|
+
* one-element shape reuses the stored format when the allowed list is unknown.
|
|
399
|
+
*
|
|
400
|
+
* @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object, existingEntity?: ?object, defaultBodyFormat?: boolean}} input
|
|
307
401
|
* @returns {Promise<object>} The same attributes object, with formats resolved.
|
|
308
402
|
*/
|
|
309
403
|
export async function applyAllowedFormatsToAttributes({
|
|
310
404
|
backend, site, entityType, bundle, attributes, existingEntity = null,
|
|
405
|
+
defaultBodyFormat = true,
|
|
311
406
|
}) {
|
|
312
407
|
const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
|
|
313
408
|
for (const fieldName of names) {
|
|
314
409
|
const value = new Map(Object.entries(attributes)).get(fieldName);
|
|
315
410
|
if (value === undefined || value === null) continue;
|
|
316
|
-
if (typeof value !== "string" &&
|
|
317
|
-
|
|
411
|
+
if (!Array.isArray(value) && typeof value !== "string" && typeof value !== "object") continue;
|
|
412
|
+
const treatAsBody = defaultBodyFormat && fieldName === "body";
|
|
318
413
|
const def = await resolveFieldDefinition(backend, site, entityType, bundle, fieldName);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
|
|
416
|
+
const restricted = Boolean(def?.allowedFormats?.length);
|
|
417
|
+
const anyShaped = value.some((item) => isFormattedShape(item));
|
|
418
|
+
if (!formattedType && !restricted && !treatAsBody && !anyShaped) continue;
|
|
419
|
+
const next = value.map((item) => resolveFormattedItem(
|
|
420
|
+
fieldName, item, def, treatAsBody, site, existingEntity,
|
|
421
|
+
));
|
|
422
|
+
Object.assign(attributes, Object.fromEntries([[fieldName, next]]));
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (typeof value !== "string" && typeof value !== "object") continue;
|
|
426
|
+
const next = resolveFormattedItem(fieldName, value, def, treatAsBody, site, existingEntity);
|
|
427
|
+
if (next !== value) {
|
|
428
|
+
Object.assign(attributes, Object.fromEntries([[fieldName, next]]));
|
|
333
429
|
}
|
|
334
|
-
const format = resolveTextFormat({
|
|
335
|
-
fieldName,
|
|
336
|
-
requested,
|
|
337
|
-
allowedFormats,
|
|
338
|
-
site,
|
|
339
|
-
defaultWhenUnknown: isBody,
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
if (format === undefined && !formattedType && !isBody && !shaped) continue;
|
|
343
|
-
Object.assign(attributes, Object.fromEntries([
|
|
344
|
-
[fieldName, normalizeFormattedValue(value, format)],
|
|
345
|
-
]));
|
|
346
430
|
}
|
|
347
431
|
return attributes;
|
|
348
432
|
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Validate text formats on caller-supplied paragraph attributes.
|
|
436
|
+
* Paragraph writes do not invent `full_html`. Do not pass attributes copied
|
|
437
|
+
* from an existing paragraph: those already have the stored format.
|
|
438
|
+
* @param {object} backend
|
|
439
|
+
* @param {object} site
|
|
440
|
+
* @param {string} bundle Paragraph bundle machine name.
|
|
441
|
+
* @param {object} attributes Mutated in place.
|
|
442
|
+
* @returns {Promise<object>}
|
|
443
|
+
*/
|
|
444
|
+
export function applyParagraphTextFormats(backend, site, bundle, attributes) {
|
|
445
|
+
return applyAllowedFormatsToAttributes({
|
|
446
|
+
backend,
|
|
447
|
+
site,
|
|
448
|
+
entityType: "paragraph",
|
|
449
|
+
bundle,
|
|
450
|
+
attributes,
|
|
451
|
+
defaultBodyFormat: false,
|
|
452
|
+
});
|
|
453
|
+
}
|
package/src/lib/nested-draft.js
CHANGED
|
@@ -21,6 +21,7 @@ import { entityRevisionId } from "./write-revision.js";
|
|
|
21
21
|
import { embedParagraphRef, paragraphRevisionId } from "./err-relationships.js";
|
|
22
22
|
import { httpStatusOf } from "./error-status.js";
|
|
23
23
|
import { OPEN_DRAFT_OPERATION, MIN_SENTINEL_COMPONENTS_VERSION, valueMatches } from "./component-draft.js";
|
|
24
|
+
import { applyParagraphTextFormats } from "./field-definition.js";
|
|
24
25
|
import { PREFLIGHT_NONE, PREFLIGHT_SENTINEL_DRAFT, dryRunChecks } from "./dry-run-checks.js";
|
|
25
26
|
|
|
26
27
|
export const NESTED_DRAFT_PARTIAL_CODE = "NESTED_DRAFT_PARTIAL";
|
|
@@ -84,11 +85,14 @@ export function nestedDraftUncertain(detail, prepared) {
|
|
|
84
85
|
/**
|
|
85
86
|
* @param {object} backend
|
|
86
87
|
* @param {object} input
|
|
87
|
-
* @param {{dryRun?: boolean, assertParagraphCreate?: (bundle: string) => void}} [options]
|
|
88
|
+
* @param {{dryRun?: boolean, assertParagraphCreate?: (bundle: string) => void, site?: object}} [options]
|
|
88
89
|
* @returns {Promise<object>}
|
|
89
90
|
*/
|
|
90
|
-
export async function draftNestedComponents(backend, input, { dryRun = false, assertParagraphCreate } = {}) {
|
|
91
|
+
export async function draftNestedComponents(backend, input, { dryRun = false, assertParagraphCreate, site = {} } = {}) {
|
|
91
92
|
const spec = normalizeNestedInput(input);
|
|
93
|
+
// Caller-supplied children and translations only. Copied parent fields are
|
|
94
|
+
// the stored paragraph, not a new format choice, and are left unchanged.
|
|
95
|
+
await applyCallerParagraphFormats(backend, site, spec.children);
|
|
92
96
|
const inventory = await readNodeDraftInventory(backend, spec);
|
|
93
97
|
if (!inventory) {
|
|
94
98
|
throw new Error(
|
|
@@ -730,9 +734,21 @@ function assertChildren(children, childPins, requiredLangs) {
|
|
|
730
734
|
}
|
|
731
735
|
|
|
732
736
|
/**
|
|
733
|
-
* @param {object}
|
|
734
|
-
* @param {
|
|
737
|
+
* @param {object} backend
|
|
738
|
+
* @param {object} site
|
|
739
|
+
* @param {object[]} children
|
|
740
|
+
* @returns {Promise<void>}
|
|
735
741
|
*/
|
|
742
|
+
async function applyCallerParagraphFormats(backend, site, children) {
|
|
743
|
+
for (const child of children) {
|
|
744
|
+
if (child.op === "keep") continue;
|
|
745
|
+
await applyParagraphTextFormats(backend, site, child.type, child.attributes);
|
|
746
|
+
for (const translation of child.translations ?? []) {
|
|
747
|
+
await applyParagraphTextFormats(backend, site, child.type, translation.attributes);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
736
752
|
function assertLangs(child, requiredLangs) {
|
|
737
753
|
const supplied = new Set(child.translations.map((row) => row.langcode));
|
|
738
754
|
for (const lang of requiredLangs) {
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-format restrictions from MCP Sentinel's site schema (#429).
|
|
3
|
+
*
|
|
4
|
+
* A content-tier token cannot read JSON:API `field_config` (that needs
|
|
5
|
+
* "administer node fields") and this connector's production configs often
|
|
6
|
+
* have no Drush bridge. Sentinel's `GET /drupal-mcp/context` is the schema
|
|
7
|
+
* that token can read. When a field entry includes `allowed_formats`, that
|
|
8
|
+
* list is Field API's restriction: one entry is the default, any other
|
|
9
|
+
* format is refused. A missing key means an older Sentinel that does not
|
|
10
|
+
* report the restriction — the caller must not treat that as "all formats".
|
|
11
|
+
* An empty array means the field has no format restriction.
|
|
12
|
+
*
|
|
13
|
+
* Dispatch fills the process cache before a tool that resolves text formats.
|
|
14
|
+
* A failed fetch is remembered only briefly so a Sentinel outage does not
|
|
15
|
+
* add the timeout to every later write, and concurrent writes share one
|
|
16
|
+
* request. Unit tests that call handlers directly can seed a successful
|
|
17
|
+
* document with `rememberTextFormatContext`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
21
|
+
|
|
22
|
+
/** How long a successful context document is reused. */
|
|
23
|
+
const CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/** How long a miss is remembered. Short, so a recovered Sentinel is retried. */
|
|
26
|
+
const FAILURE_TTL_MS = 30 * 1000;
|
|
27
|
+
|
|
28
|
+
/** Context fetch budget. A miss must not stall the write past this. */
|
|
29
|
+
const CONTEXT_TIMEOUT_MS = 8000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tools whose handlers call the text-format resolver. Other writes must not
|
|
33
|
+
* wait on this document.
|
|
34
|
+
*/
|
|
35
|
+
const TEXT_FORMAT_TOOLS = new Set([
|
|
36
|
+
"drupal_create_node",
|
|
37
|
+
"drupal_update_node",
|
|
38
|
+
"drupal_entity_create",
|
|
39
|
+
"drupal_entity_update",
|
|
40
|
+
"drupal_create_translation",
|
|
41
|
+
"drupal_bulk_create",
|
|
42
|
+
"drupal_bulk_update",
|
|
43
|
+
"drupal_create_paragraph",
|
|
44
|
+
"drupal_update_paragraph",
|
|
45
|
+
"drupal_create_media",
|
|
46
|
+
"drupal_update_media",
|
|
47
|
+
"drupal_draft_nested_components",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** @type {Map<string, {at: number, document: ?object, ttl: number}>} */
|
|
51
|
+
const cache = new Map();
|
|
52
|
+
|
|
53
|
+
/** @type {Map<string, {slot: object, promise: Promise<void>}>} */
|
|
54
|
+
const inflight = new Map();
|
|
55
|
+
|
|
56
|
+
/** @type {(site: object) => Promise<?object>} */
|
|
57
|
+
let fetcher = async () => null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Cache key for one configured site. Name and base URL both matter so two
|
|
61
|
+
* sites that share a name in different processes cannot collide here, and a
|
|
62
|
+
* renamed base URL does not reuse the previous document.
|
|
63
|
+
* @param {object} site Resolved site config.
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function cacheKey(site) {
|
|
67
|
+
return `${site?._name ?? ""}\0${site?.baseUrl ?? ""}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drop cached schema documents. Tests call this between cases.
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
export function clearTextFormatContextCache() {
|
|
75
|
+
cache.clear();
|
|
76
|
+
inflight.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether this tool resolves text formats and should wait for context.
|
|
81
|
+
* @param {string} toolName
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function toolNeedsTextFormatContext(toolName) {
|
|
85
|
+
return TEXT_FORMAT_TOOLS.has(toolName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Seed the cache without a network call. A seeded document uses the success TTL.
|
|
90
|
+
* @param {object} site Resolved site config.
|
|
91
|
+
* @param {object} document Sentinel context JSON.
|
|
92
|
+
* @returns {void}
|
|
93
|
+
*/
|
|
94
|
+
export function rememberTextFormatContext(site, document) {
|
|
95
|
+
cache.set(cacheKey(site), { at: Date.now(), document, ttl: CONTEXT_TTL_MS });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} key
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
function fresh(key) {
|
|
103
|
+
const hit = cache.get(key);
|
|
104
|
+
if (!hit) return false;
|
|
105
|
+
return Date.now() - hit.at < hit.ttl;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Install the process fetcher. The server entry calls this with
|
|
110
|
+
* {@link fetchTextFormatContext}. Tests leave the default, which returns
|
|
111
|
+
* null and does not touch the network.
|
|
112
|
+
* @param {(site: object) => Promise<?object>} next
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
export function installTextFormatContextFetcher(next) {
|
|
116
|
+
fetcher = next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load `GET /drupal-mcp/context` when the cache is cold. A failure is cached
|
|
121
|
+
* as a miss for 30 seconds: the write keeps the historical path
|
|
122
|
+
* for an unknown format list, and the next write does not wait again.
|
|
123
|
+
* Concurrent callers share one in-flight request.
|
|
124
|
+
* @param {object} site Resolved site config.
|
|
125
|
+
* @returns {Promise<void>}
|
|
126
|
+
*/
|
|
127
|
+
export async function ensureTextFormatContext(site) {
|
|
128
|
+
if (!site?.baseUrl) return;
|
|
129
|
+
const key = cacheKey(site);
|
|
130
|
+
if (fresh(key)) return;
|
|
131
|
+
const pending = inflight.get(key);
|
|
132
|
+
if (pending) {
|
|
133
|
+
await pending.promise;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Identity is a plain object. Comparing the promise itself is what CodeQL
|
|
137
|
+
// reports as a missing await.
|
|
138
|
+
const slot = {};
|
|
139
|
+
const promise = loadTextFormatContext(site, key);
|
|
140
|
+
inflight.set(key, { slot, promise });
|
|
141
|
+
try {
|
|
142
|
+
await promise;
|
|
143
|
+
} finally {
|
|
144
|
+
const row = inflight.get(key);
|
|
145
|
+
if (row?.slot === slot) inflight.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {object} site
|
|
151
|
+
* @param {string} key
|
|
152
|
+
* @returns {Promise<void>}
|
|
153
|
+
*/
|
|
154
|
+
async function loadTextFormatContext(site, key) {
|
|
155
|
+
let document = null;
|
|
156
|
+
try {
|
|
157
|
+
const loaded = await fetcher(site);
|
|
158
|
+
if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) document = loaded;
|
|
159
|
+
} catch {
|
|
160
|
+
document = null;
|
|
161
|
+
}
|
|
162
|
+
cache.set(key, {
|
|
163
|
+
at: Date.now(),
|
|
164
|
+
document,
|
|
165
|
+
ttl: document ? CONTEXT_TTL_MS : FAILURE_TTL_MS,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* GET /drupal-mcp/context as the site's own principal.
|
|
171
|
+
* @param {object} site Resolved site config.
|
|
172
|
+
* @returns {Promise<?object>} The JSON document, or null on any miss.
|
|
173
|
+
*/
|
|
174
|
+
export async function fetchTextFormatContext(site) {
|
|
175
|
+
let headers;
|
|
176
|
+
try {
|
|
177
|
+
headers = {
|
|
178
|
+
Accept: "application/json",
|
|
179
|
+
...clientHeaders(),
|
|
180
|
+
...(await authHeadersAsync(site)),
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
let res;
|
|
186
|
+
try {
|
|
187
|
+
res = await fetch(`${site.baseUrl}/drupal-mcp/context`, {
|
|
188
|
+
method: "GET",
|
|
189
|
+
headers,
|
|
190
|
+
signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS),
|
|
191
|
+
});
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok) return null;
|
|
196
|
+
try {
|
|
197
|
+
const body = await res.json();
|
|
198
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
199
|
+
if (!Object.prototype.hasOwnProperty.call(body, "content_types")) return null;
|
|
200
|
+
return body;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Field definition from a cached context document.
|
|
208
|
+
* Only node bundles are present. A field with no `allowed_formats` key is
|
|
209
|
+
* unknown (older Sentinel), not unrestricted.
|
|
210
|
+
* @param {object} site Resolved site config.
|
|
211
|
+
* @param {string} entityType
|
|
212
|
+
* @param {string} bundle
|
|
213
|
+
* @param {string} fieldName
|
|
214
|
+
* @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
|
|
215
|
+
*/
|
|
216
|
+
export function textFormatDefinitionFromContext(site, entityType, bundle, fieldName) {
|
|
217
|
+
if (entityType !== "node") return null;
|
|
218
|
+
const hit = cache.get(cacheKey(site));
|
|
219
|
+
// A cached miss (document null) is not a schema. Do not invent a list.
|
|
220
|
+
if (!hit?.document || !fresh(cacheKey(site))) return null;
|
|
221
|
+
const types = hit.document?.content_types;
|
|
222
|
+
if (!types || typeof types !== "object" || Array.isArray(types)) return null;
|
|
223
|
+
const schema = new Map(Object.entries(types)).get(bundle);
|
|
224
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return null;
|
|
225
|
+
const fields = new Map(Object.entries(schema)).get("fields");
|
|
226
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return null;
|
|
227
|
+
const field = new Map(Object.entries(fields)).get(fieldName);
|
|
228
|
+
if (!field || typeof field !== "object" || Array.isArray(field)) return null;
|
|
229
|
+
const attrs = new Map(Object.entries(field));
|
|
230
|
+
if (!attrs.has("allowed_formats")) return null;
|
|
231
|
+
const fieldType = attrs.get("type");
|
|
232
|
+
return {
|
|
233
|
+
fieldName,
|
|
234
|
+
fieldType: typeof fieldType === "string" ? fieldType : null,
|
|
235
|
+
allowedFormats: normalizeContextFormats(attrs.get("allowed_formats")),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param {*} raw `allowed_formats` from the context document.
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
function normalizeContextFormats(raw) {
|
|
244
|
+
const values = Array.isArray(raw)
|
|
245
|
+
? raw
|
|
246
|
+
: (raw && typeof raw === "object" ? [...new Map(Object.entries(raw)).values()] : []);
|
|
247
|
+
return values.filter((value) => typeof value === "string" && value !== "" && value !== "0");
|
|
248
|
+
}
|
package/src/tools/bulk.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
22
22
|
} from "../lib/err-relationships.js";
|
|
23
23
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
24
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Normalize an unknown thrown value into a human-readable message.
|
|
@@ -53,11 +54,15 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
53
54
|
for (const [index, rawItem] of items.entries()) {
|
|
54
55
|
const item = rawItem || {};
|
|
55
56
|
try {
|
|
56
|
-
|
|
57
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
58
|
+
backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
|
|
59
|
+
defaultBodyFormat: false,
|
|
60
|
+
});
|
|
61
|
+
assertPublishAllowed(sec, formatted);
|
|
57
62
|
const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
|
|
58
63
|
const entity = await backend.createEntity({
|
|
59
64
|
entityType, bundle,
|
|
60
|
-
attributes: withUnpublishedDefault(sec, entityType,
|
|
65
|
+
attributes: withUnpublishedDefault(sec, entityType, formatted),
|
|
61
66
|
relationships: resolvedRelationships,
|
|
62
67
|
});
|
|
63
68
|
created += 1;
|
|
@@ -107,9 +112,13 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
|
|
|
107
112
|
const existing = await readUpdateTarget(backend, {
|
|
108
113
|
entityType, bundle, id: item.id, attributes: item.attributes ?? {},
|
|
109
114
|
});
|
|
115
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
116
|
+
backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
|
|
117
|
+
existingEntity: existing, defaultBodyFormat: false,
|
|
118
|
+
});
|
|
110
119
|
const attributes = await applySafeDraftDefault({
|
|
111
120
|
backend, entityType, bundle, id: item.id,
|
|
112
|
-
attributes:
|
|
121
|
+
attributes: formatted,
|
|
113
122
|
existingEntity: existing,
|
|
114
123
|
});
|
|
115
124
|
assertPublishAllowed(sec, attributes);
|
package/src/tools/entities.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
21
|
import { readUpdateTarget } from "../lib/canonical-draft.js";
|
|
22
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
22
23
|
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
23
24
|
import {
|
|
24
25
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
@@ -70,9 +71,12 @@ async function createEntity({ site: siteName, entityType, bundle, attributes: ca
|
|
|
70
71
|
const site = getSiteConfig(siteName);
|
|
71
72
|
const sec = resolveSecurityConfig(site);
|
|
72
73
|
assertWriteAllowed(sec, "create", entityType, bundle);
|
|
73
|
-
assertPublishAllowed(sec, callerAttributes);
|
|
74
|
-
const attributes = withUnpublishedDefault(sec, entityType, callerAttributes);
|
|
75
74
|
const backend = await resolveBackend(site);
|
|
75
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
76
|
+
backend, site, entityType, bundle, attributes: { ...callerAttributes }, defaultBodyFormat: false,
|
|
77
|
+
});
|
|
78
|
+
assertPublishAllowed(sec, formatted);
|
|
79
|
+
const attributes = withUnpublishedDefault(sec, entityType, formatted);
|
|
76
80
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
77
81
|
if (dryRun) {
|
|
78
82
|
return {
|
|
@@ -113,8 +117,12 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
113
117
|
// unrequested-status-change flag. Skipped when the caller pinned the
|
|
114
118
|
// moderation state explicitly (same condition the draft default uses).
|
|
115
119
|
const existing = await readUpdateTarget(backend, { entityType, bundle, id, attributes });
|
|
120
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
121
|
+
backend, site, entityType, bundle, attributes: { ...attributes }, existingEntity: existing,
|
|
122
|
+
defaultBodyFormat: false,
|
|
123
|
+
});
|
|
116
124
|
const safeAttributes = await applySafeDraftDefault({
|
|
117
|
-
backend, entityType, bundle, id, attributes, existingEntity: existing,
|
|
125
|
+
backend, entityType, bundle, id, attributes: formatted, existingEntity: existing,
|
|
118
126
|
});
|
|
119
127
|
assertPublishAllowed(sec, safeAttributes);
|
|
120
128
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
package/src/tools/media.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../lib/sentinel-draft.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
21
|
import { entityRevisionId } from "../lib/write-revision.js";
|
|
22
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* List all media types (bundles of the media entity type).
|
|
@@ -110,8 +111,11 @@ async function createMedia({ site: siteName, type, name, status = false, fields
|
|
|
110
111
|
// Layer name/status after fields so they win, matching prior behaviour.
|
|
111
112
|
attributes.name = name;
|
|
112
113
|
attributes.status = status;
|
|
113
|
-
assertPublishAllowed(sec, attributes);
|
|
114
114
|
const backend = await resolveBackend(site);
|
|
115
|
+
await applyAllowedFormatsToAttributes({
|
|
116
|
+
backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
|
|
117
|
+
});
|
|
118
|
+
assertPublishAllowed(sec, attributes);
|
|
115
119
|
return backend.createEntity({
|
|
116
120
|
entityType: "media", bundle: type, attributes,
|
|
117
121
|
...(relationships ? { relationships } : {}),
|
|
@@ -134,8 +138,11 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
|
|
|
134
138
|
const { attributes, relationships } = splitReferenceFields(fields);
|
|
135
139
|
if (name !== undefined) attributes.name = name;
|
|
136
140
|
if (status !== undefined) attributes.status = status;
|
|
137
|
-
assertPublishAllowed(sec, attributes);
|
|
138
141
|
const backend = await resolveBackend(site);
|
|
142
|
+
await applyAllowedFormatsToAttributes({
|
|
143
|
+
backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
|
|
144
|
+
});
|
|
145
|
+
assertPublishAllowed(sec, attributes);
|
|
139
146
|
if (langcode) {
|
|
140
147
|
const targetLang = assertDraftLangcode(langcode);
|
|
141
148
|
const patchTarget = await prepareGuardedPatch(backend, {
|
package/src/tools/nested.js
CHANGED
package/src/tools/nodes.js
CHANGED
|
@@ -516,7 +516,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
516
516
|
const written = await writeComponentDraft(backend, {
|
|
517
517
|
entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
|
|
518
518
|
components: normalizedComponents, langcode,
|
|
519
|
-
}, { dryRun });
|
|
519
|
+
}, { dryRun, site });
|
|
520
520
|
const shaped = dryRun ? written : shapeWriteResponse(written, returning);
|
|
521
521
|
return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
|
|
522
522
|
}
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { getSiteConfig } from "../lib/config.js";
|
|
38
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
38
39
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
39
40
|
import {
|
|
40
41
|
resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
|
|
@@ -93,7 +94,11 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
|
|
|
93
94
|
const sec = resolveSecurityConfig(site);
|
|
94
95
|
assertWriteAllowed(sec, "create", "paragraph", paragraphType);
|
|
95
96
|
const backend = await resolveBackend(site);
|
|
96
|
-
const
|
|
97
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
98
|
+
backend, site, entityType: "paragraph", bundle: paragraphType,
|
|
99
|
+
attributes: { ...attributes }, defaultBodyFormat: false,
|
|
100
|
+
});
|
|
101
|
+
const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes: formatted });
|
|
97
102
|
const bundle = paragraph.bundle || paragraphType;
|
|
98
103
|
const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
|
|
99
104
|
if (revisionId === null) throw missingParagraphRevisionError(paragraph.id);
|
|
@@ -122,6 +127,10 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
|
|
|
122
127
|
const sec = resolveSecurityConfig(site);
|
|
123
128
|
assertWriteAllowed(sec, "update", "paragraph", paragraphType);
|
|
124
129
|
const backend = await resolveBackend(site);
|
|
130
|
+
const formatted = await applyAllowedFormatsToAttributes({
|
|
131
|
+
backend, site, entityType: "paragraph", bundle: paragraphType,
|
|
132
|
+
attributes: { ...attributes }, defaultBodyFormat: false,
|
|
133
|
+
});
|
|
125
134
|
let paragraph;
|
|
126
135
|
if (langcode) {
|
|
127
136
|
const targetLang = assertDraftLangcode(langcode);
|
|
@@ -132,11 +141,11 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
|
|
|
132
141
|
throw new Error("Paragraph translation update requires a paragraph revision ID (the host pin).");
|
|
133
142
|
}
|
|
134
143
|
paragraph = await writeDraft(backend, {
|
|
135
|
-
entityType: "paragraph", bundle: paragraphType, id, attributes, langcode: targetLang, draftState,
|
|
144
|
+
entityType: "paragraph", bundle: paragraphType, id, attributes: formatted, langcode: targetLang, draftState,
|
|
136
145
|
draftRevision: { revisionId: pinned },
|
|
137
146
|
});
|
|
138
147
|
} else {
|
|
139
|
-
paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
|
|
148
|
+
paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes: formatted });
|
|
140
149
|
}
|
|
141
150
|
const bundle = paragraph.bundle || paragraphType;
|
|
142
151
|
const resolvedRevisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../lib/security.js";
|
|
29
29
|
import { validateUuid, validateMachineName } from "../lib/validate.js";
|
|
30
30
|
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
31
|
+
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
31
32
|
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
32
33
|
import { entityRevisionId } from "../lib/write-revision.js";
|
|
33
34
|
import { paragraphRevisionId } from "../lib/err-relationships.js";
|
|
@@ -160,7 +161,10 @@ async function createTranslation({
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
let draftRevision;
|
|
163
|
-
let drafted = {
|
|
164
|
+
let drafted = await applyAllowedFormatsToAttributes({
|
|
165
|
+
backend, site, entityType, bundle: type, attributes: { ...attributes }, existingEntity: existing,
|
|
166
|
+
defaultBodyFormat: false,
|
|
167
|
+
});
|
|
164
168
|
delete drafted.langcode;
|
|
165
169
|
if (entityType === "paragraph") {
|
|
166
170
|
const pinned = revisionId ?? paragraphRevisionId(existing);
|