drupal-mcp-connector 2.7.4 → 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/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
- package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
- package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
- package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
- package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
- package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-search.md +3 -4
- package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
- package/CHANGELOG.md +56 -0
- package/README.md +21 -11
- 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 +36 -15
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +14 -0
- package/src/lib/server-tools.js +9 -0
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +3 -2
- package/src/tools/nodes.js +55 -43
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoritative text-format resolution for node writes (#168).
|
|
3
|
+
*
|
|
4
|
+
* `backend.getEntitySchema` is sampling-only: it can label a field
|
|
5
|
+
* `text_formatted` / `text_with_summary` but does NOT expose Field API
|
|
6
|
+
* `allowed_formats`. Never invent that list from `defaultTextFormat` or
|
|
7
|
+
* `FALLBACK_TEXT_FORMAT` (`full_html`).
|
|
8
|
+
*
|
|
9
|
+
* Resolution chain for a field definition:
|
|
10
|
+
* 1. `backend.getFieldDefinition` — JSON:API `field_config` via an internal
|
|
11
|
+
* adapter fetch (not `drupal_entity_get`; `field_config` is on the agent
|
|
12
|
+
* deny list).
|
|
13
|
+
* 2. Drush `config:get field.field.{entityType}.{bundle}.{field}` when a
|
|
14
|
+
* Drush bridge is configured.
|
|
15
|
+
* 3. If the definition cannot be resolved: return null. Callers keep the
|
|
16
|
+
* historical default chain only while the list is unknown. Once the list
|
|
17
|
+
* is known, never persist a format outside it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { drushConfigured } from "./audit-sources.js";
|
|
21
|
+
import { validateMachineName } from "./validate.js";
|
|
22
|
+
import { parseDrush, sshDrush } from "../tools/drush.js";
|
|
23
|
+
|
|
24
|
+
/** Last-resort body format when the Field API list cannot be resolved. */
|
|
25
|
+
export const FALLBACK_TEXT_FORMAT = "full_html";
|
|
26
|
+
|
|
27
|
+
const FORMATTED_FIELD_TYPES = new Set(["text", "text_long", "text_with_summary"]);
|
|
28
|
+
|
|
29
|
+
const SKIP_FORMAT_FIELDS = new Set([
|
|
30
|
+
"title", "status", "moderation_state", "path", "promote", "sticky",
|
|
31
|
+
"created", "changed", "langcode",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a Field API `allowed_formats` value to a string list.
|
|
36
|
+
* @param {*} raw settings.allowed_formats from field_config / Drush.
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function asFormatList(raw) {
|
|
40
|
+
if (Array.isArray(raw)) return raw.filter((f) => typeof f === "string" && f);
|
|
41
|
+
if (raw && typeof raw === "object") {
|
|
42
|
+
return [...new Map(Object.entries(raw)).values()].filter((f) => typeof f === "string" && f);
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a field_config resource or `field.field.*` config object.
|
|
49
|
+
* @param {object} obj Config / JSON:API attributes.
|
|
50
|
+
* @param {string} [fallbackName] Field name when the object omits `field_name`.
|
|
51
|
+
* @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
|
|
52
|
+
*/
|
|
53
|
+
export function parseFieldConfigObject(obj, fallbackName) {
|
|
54
|
+
if (!obj || typeof obj !== "object") return null;
|
|
55
|
+
const attrs = new Map(Object.entries(obj));
|
|
56
|
+
const fieldName = attrs.get("field_name") || fallbackName;
|
|
57
|
+
if (typeof fieldName !== "string" || !fieldName) return null;
|
|
58
|
+
const settings = attrs.get("settings");
|
|
59
|
+
const settingsMap = settings && typeof settings === "object" && !Array.isArray(settings)
|
|
60
|
+
? new Map(Object.entries(settings))
|
|
61
|
+
: new Map();
|
|
62
|
+
const fieldType = attrs.get("field_type");
|
|
63
|
+
return {
|
|
64
|
+
fieldName,
|
|
65
|
+
fieldType: typeof fieldType === "string" ? fieldType : null,
|
|
66
|
+
allowedFormats: asFormatList(settingsMap.get("allowed_formats")),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pick the config object out of a Drush `config:get --format=json` payload.
|
|
72
|
+
* @param {*} raw Parsed Drush JSON.
|
|
73
|
+
* @param {string} configName `field.field.{type}.{bundle}.{field}`.
|
|
74
|
+
* @returns {?object}
|
|
75
|
+
*/
|
|
76
|
+
function unwrapDrushConfig(raw, configName) {
|
|
77
|
+
if (!raw || typeof raw !== "object") return null;
|
|
78
|
+
const map = new Map(Object.entries(raw));
|
|
79
|
+
if (map.has("field_type") || map.has("settings")) return raw;
|
|
80
|
+
const named = map.get(configName);
|
|
81
|
+
if (named && typeof named === "object") return named;
|
|
82
|
+
if (map.size === 1) {
|
|
83
|
+
const only = [...map.values()][0];
|
|
84
|
+
if (only && typeof only === "object") return only;
|
|
85
|
+
}
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load a field definition via Drush `config:get` (chain step 2).
|
|
91
|
+
* @param {object} site Site config with `drushSsh`.
|
|
92
|
+
* @param {string} entityType
|
|
93
|
+
* @param {string} bundle
|
|
94
|
+
* @param {string} fieldName
|
|
95
|
+
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
96
|
+
*/
|
|
97
|
+
export async function fieldDefinitionFromDrush(site, entityType, bundle, fieldName) {
|
|
98
|
+
validateMachineName(entityType, "entityType");
|
|
99
|
+
validateMachineName(bundle, "bundle");
|
|
100
|
+
validateMachineName(fieldName, "fieldName");
|
|
101
|
+
const configName = `field.field.${entityType}.${bundle}.${fieldName}`;
|
|
102
|
+
try {
|
|
103
|
+
const raw = parseDrush(await sshDrush(site, ["config:get", configName, "--format=json"]));
|
|
104
|
+
return parseFieldConfigObject(unwrapDrushConfig(raw, configName), fieldName);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve Field API metadata. See the file header for the chain.
|
|
112
|
+
* @param {object} backend Resolved backend.
|
|
113
|
+
* @param {object} site Site config.
|
|
114
|
+
* @param {string} entityType
|
|
115
|
+
* @param {string} bundle
|
|
116
|
+
* @param {string} fieldName
|
|
117
|
+
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
118
|
+
*/
|
|
119
|
+
export async function resolveFieldDefinition(backend, site, entityType, bundle, fieldName) {
|
|
120
|
+
if (typeof backend?.getFieldDefinition === "function") {
|
|
121
|
+
try {
|
|
122
|
+
const def = await backend.getFieldDefinition({ entityType, bundle, fieldName });
|
|
123
|
+
if (def) return parseFieldConfigObject({
|
|
124
|
+
field_name: def.fieldName,
|
|
125
|
+
field_type: def.fieldType,
|
|
126
|
+
settings: { allowed_formats: def.allowedFormats },
|
|
127
|
+
}, fieldName) ?? def;
|
|
128
|
+
} catch {
|
|
129
|
+
// JSON:API field_config is optional; try Drush next.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (drushConfigured(site)) {
|
|
133
|
+
const def = await fieldDefinitionFromDrush(site, entityType, bundle, fieldName);
|
|
134
|
+
if (def) return def;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Choose the format to persist, or throw before mutation.
|
|
141
|
+
*
|
|
142
|
+
* Known list + exactly one entry → that format when the caller omits one.
|
|
143
|
+
* Known list + caller format outside it → refuse.
|
|
144
|
+
* Known list + several entries + omitted format → site `defaultTextFormat`
|
|
145
|
+
* only if it is in the list; otherwise refuse (never `full_html` when it
|
|
146
|
+
* is not allowed).
|
|
147
|
+
* Unknown list (`allowedFormats` is null): historical default chain only
|
|
148
|
+
* when `defaultWhenUnknown` is true (body); otherwise leave format omitted.
|
|
149
|
+
*
|
|
150
|
+
* @param {{fieldName: string, requested: ?string, allowedFormats: ?string[],
|
|
151
|
+
* site?: object, defaultWhenUnknown?: boolean}} input
|
|
152
|
+
* @returns {string|undefined}
|
|
153
|
+
*/
|
|
154
|
+
export function resolveTextFormat({
|
|
155
|
+
fieldName, requested, allowedFormats, site, defaultWhenUnknown = false,
|
|
156
|
+
}) {
|
|
157
|
+
const asked = requested === undefined || requested === null || requested === ""
|
|
158
|
+
? undefined
|
|
159
|
+
: String(requested);
|
|
160
|
+
|
|
161
|
+
if (!Array.isArray(allowedFormats)) {
|
|
162
|
+
if (asked !== undefined) return asked;
|
|
163
|
+
if (!defaultWhenUnknown) return undefined;
|
|
164
|
+
return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (allowedFormats.length === 0) {
|
|
168
|
+
if (asked !== undefined) return asked;
|
|
169
|
+
if (!defaultWhenUnknown) return undefined;
|
|
170
|
+
return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (asked !== undefined) {
|
|
174
|
+
if (!allowedFormats.includes(asked)) {
|
|
175
|
+
throw textFormatError(fieldName, asked, allowedFormats);
|
|
176
|
+
}
|
|
177
|
+
return asked;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (allowedFormats.length === 1) {
|
|
181
|
+
return allowedFormats[0];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const siteDefault = site?.defaultTextFormat;
|
|
185
|
+
if (siteDefault && allowedFormats.includes(siteDefault)) {
|
|
186
|
+
return siteDefault;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw textFormatError(fieldName, siteDefault ?? FALLBACK_TEXT_FORMAT, allowedFormats);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string} fieldName
|
|
194
|
+
* @param {string} requested
|
|
195
|
+
* @param {string[]} allowed
|
|
196
|
+
* @returns {Error}
|
|
197
|
+
*/
|
|
198
|
+
function textFormatError(fieldName, requested, allowed) {
|
|
199
|
+
return new Error(
|
|
200
|
+
`Field "${fieldName}" does not allow format "${requested}" ` +
|
|
201
|
+
`(allowed: ${allowed.join(", ")}).`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether a payload value is already a formatted-text object.
|
|
207
|
+
* @param {*} value
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
function isFormattedShape(value) {
|
|
211
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
212
|
+
if (!Object.prototype.hasOwnProperty.call(value, "value")) return false;
|
|
213
|
+
return ["format", "summary", "processed"].some((k) => Object.prototype.hasOwnProperty.call(value, k));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {*} value
|
|
218
|
+
* @returns {string|undefined}
|
|
219
|
+
*/
|
|
220
|
+
function requestedFormatOf(value) {
|
|
221
|
+
if (value && typeof value === "object" && !Array.isArray(value)
|
|
222
|
+
&& Object.prototype.hasOwnProperty.call(value, "format")) {
|
|
223
|
+
return value.format;
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {*} raw
|
|
230
|
+
* @param {string|undefined} format
|
|
231
|
+
* @returns {{value: *, format?: string, summary?: *}}
|
|
232
|
+
*/
|
|
233
|
+
function normalizeFormattedValue(raw, format) {
|
|
234
|
+
if (typeof raw === "string") {
|
|
235
|
+
const out = { value: raw };
|
|
236
|
+
if (format !== undefined) out.format = format;
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
const out = { value: raw.value };
|
|
240
|
+
if (format !== undefined) out.format = format;
|
|
241
|
+
if (Object.prototype.hasOwnProperty.call(raw, "summary")) out.summary = raw.summary;
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Default and validate text formats on a write attribute map (mutates it).
|
|
247
|
+
* Same checks run for dry-run and real writes so a disallowed format never
|
|
248
|
+
* reaches create/update.
|
|
249
|
+
*
|
|
250
|
+
* @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object}} input
|
|
251
|
+
* @returns {Promise<object>} The same attributes object, with formats resolved.
|
|
252
|
+
*/
|
|
253
|
+
export async function applyAllowedFormatsToAttributes({
|
|
254
|
+
backend, site, entityType, bundle, attributes,
|
|
255
|
+
}) {
|
|
256
|
+
const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
|
|
257
|
+
for (const fieldName of names) {
|
|
258
|
+
const value = new Map(Object.entries(attributes)).get(fieldName);
|
|
259
|
+
if (value === undefined || value === null) continue;
|
|
260
|
+
if (typeof value !== "string" && (typeof value !== "object" || Array.isArray(value))) continue;
|
|
261
|
+
|
|
262
|
+
const def = await resolveFieldDefinition(backend, site, entityType, bundle, fieldName);
|
|
263
|
+
const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
|
|
264
|
+
const restricted = Boolean(def?.allowedFormats?.length);
|
|
265
|
+
const isBody = fieldName === "body";
|
|
266
|
+
const shaped = isFormattedShape(value);
|
|
267
|
+
|
|
268
|
+
if (def && !formattedType && !restricted && !isBody) continue;
|
|
269
|
+
if (!def && !shaped && !isBody) continue;
|
|
270
|
+
|
|
271
|
+
const format = resolveTextFormat({
|
|
272
|
+
fieldName,
|
|
273
|
+
requested: requestedFormatOf(value),
|
|
274
|
+
allowedFormats: def ? def.allowedFormats : null,
|
|
275
|
+
site,
|
|
276
|
+
defaultWhenUnknown: isBody,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (format === undefined && !formattedType && !isBody && !shaped) continue;
|
|
280
|
+
Object.assign(attributes, Object.fromEntries([
|
|
281
|
+
[fieldName, normalizeFormattedValue(value, format)],
|
|
282
|
+
]));
|
|
283
|
+
}
|
|
284
|
+
return attributes;
|
|
285
|
+
}
|
|
@@ -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
|
|
|
@@ -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
|
}
|