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,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Northbound data-flow budgets (#179).
|
|
3
|
+
*
|
|
4
|
+
* Binds the same finite budget classes mcp_sentinel already enforces
|
|
5
|
+
* (rows, bytes, pages, requests, chained actions) to the inbound principal
|
|
6
|
+
* and the authoritative target. Budget counters are keyed by those two
|
|
7
|
+
* values — never by MCP session, client IP, or a caller-supplied field —
|
|
8
|
+
* so pagination, retries, batching, and a new chain id cannot reset them.
|
|
9
|
+
*
|
|
10
|
+
* Classification and destination travel on the governed request as the
|
|
11
|
+
* source's narrow-only wire contract (X-MCP-Declared-Ceiling /
|
|
12
|
+
* X-MCP-Declared-Destination). Denials name a stable reason and a
|
|
13
|
+
* correlation id and never echo restricted payload.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
|
+
import { SecurityError } from "./security.js";
|
|
19
|
+
|
|
20
|
+
/** Wire header: narrow-only classification ceiling (mcp_sentinel 2.9.0). */
|
|
21
|
+
export const HEADER_DECLARED_CEILING = "X-MCP-Declared-Ceiling";
|
|
22
|
+
|
|
23
|
+
/** Wire header: declared destination, recorded in source evidence. */
|
|
24
|
+
export const HEADER_DECLARED_DESTINATION = "X-MCP-Declared-Destination";
|
|
25
|
+
|
|
26
|
+
/** Stable source / connector reason codes — do not invent a second set. */
|
|
27
|
+
export const REASON_READ = "read_budget_exceeded";
|
|
28
|
+
export const REASON_PAGE = "page_budget_exceeded";
|
|
29
|
+
export const REASON_RESPONSE_SIZE = "response_size_cap_exceeded";
|
|
30
|
+
export const REASON_CHAINED_ACTION = "chained_action_budget_exceeded";
|
|
31
|
+
export const REASON_BUDGET_CONTEXT_MISSING = "budget_context_missing";
|
|
32
|
+
|
|
33
|
+
/** Codes the source already emits; remapped onto a connector correlation id. */
|
|
34
|
+
export const SOURCE_BUDGET_REASONS = Object.freeze([
|
|
35
|
+
REASON_READ,
|
|
36
|
+
REASON_PAGE,
|
|
37
|
+
REASON_RESPONSE_SIZE,
|
|
38
|
+
REASON_CHAINED_ACTION,
|
|
39
|
+
"classification_egress_denied",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Built-in defaults — identical to mcp_sentinel McpReadBudgetResolver.
|
|
44
|
+
* A second model is out of scope; these numbers are the shared floor.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_BUDGETS = Object.freeze({
|
|
47
|
+
results: 500,
|
|
48
|
+
bytes: 8388608,
|
|
49
|
+
requests: 600,
|
|
50
|
+
requestWindowSec: 60,
|
|
51
|
+
pages: 120,
|
|
52
|
+
pageWindowSec: 60,
|
|
53
|
+
chainedActions: 600,
|
|
54
|
+
chainedActionWindowSec: 60,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const DECLARATION_MAX_LENGTH = 128;
|
|
58
|
+
const DECLARATION_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
|
59
|
+
|
|
60
|
+
const store = new AsyncLocalStorage();
|
|
61
|
+
|
|
62
|
+
/** @type {Map<string, {count: number, resetAt: number}>} */
|
|
63
|
+
const windows = new Map();
|
|
64
|
+
|
|
65
|
+
const WINDOW_KINDS = new Set(["request", "page", "chained_action"]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Denial for a data-flow budget or missing request-scoped context.
|
|
69
|
+
* Extends SecurityError so tools/call already wraps it as Access denied.
|
|
70
|
+
*/
|
|
71
|
+
export class DataFlowBudgetError extends SecurityError {
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} reason Stable reason code.
|
|
74
|
+
* @param {string} correlationId Request-scoped correlation id.
|
|
75
|
+
* @param {string} [budget] Budget class (requests, pages, rows, bytes, chained_actions).
|
|
76
|
+
*/
|
|
77
|
+
constructor(reason, correlationId, budget) {
|
|
78
|
+
super(`${reason} (correlation ${correlationId})`);
|
|
79
|
+
this.name = "DataFlowBudgetError";
|
|
80
|
+
this.reason = reason;
|
|
81
|
+
this.correlationId = correlationId;
|
|
82
|
+
this.budget = budget;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Process-global budget key for an inbound identity. Session ids are never used.
|
|
88
|
+
* @param {object|null} identity
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function principalBudgetKey(identity) {
|
|
92
|
+
if (!identity) return "local-operator";
|
|
93
|
+
const sub = typeof identity.sub === "string" && identity.sub.trim()
|
|
94
|
+
? identity.sub.trim()
|
|
95
|
+
: "-";
|
|
96
|
+
const clientId = typeof identity.clientId === "string" && identity.clientId.trim()
|
|
97
|
+
? identity.clientId.trim()
|
|
98
|
+
: "-";
|
|
99
|
+
return `${sub}:${clientId}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string|undefined} value
|
|
104
|
+
* @returns {string|undefined}
|
|
105
|
+
*/
|
|
106
|
+
function sanitizeDeclaration(value) {
|
|
107
|
+
if (typeof value !== "string") return undefined;
|
|
108
|
+
const trimmed = value.trim();
|
|
109
|
+
if (!trimmed || trimmed.length > DECLARATION_MAX_LENGTH) return undefined;
|
|
110
|
+
return DECLARATION_PATTERN.test(trimmed) ? trimmed : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {object} [raw]
|
|
115
|
+
* @returns {typeof DEFAULT_BUDGETS}
|
|
116
|
+
*/
|
|
117
|
+
export function resolveBudgetLimits(raw = {}) {
|
|
118
|
+
const merged = new Map(Object.entries(DEFAULT_BUDGETS));
|
|
119
|
+
const supplied = new Map(Object.entries(raw));
|
|
120
|
+
for (const [key, fallback] of Object.entries(DEFAULT_BUDGETS)) {
|
|
121
|
+
const value = supplied.get(key);
|
|
122
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
123
|
+
merged.set(key, value);
|
|
124
|
+
} else {
|
|
125
|
+
merged.set(key, fallback);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return Object.fromEntries(merged);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build request-scoped enforcement context. Target is required; identity may
|
|
133
|
+
* be null (local operator). Caller-supplied session fields are ignored.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} params
|
|
136
|
+
* @param {object|null} [params.identity]
|
|
137
|
+
* @param {?{name?: string, baseUrl?: string, source?: string}} params.target
|
|
138
|
+
* @param {object} [params.site]
|
|
139
|
+
* @param {object} [params.limits]
|
|
140
|
+
* @param {() => number} [params.now]
|
|
141
|
+
* @param {string} [params.correlationId]
|
|
142
|
+
* @returns {object}
|
|
143
|
+
* @throws {DataFlowBudgetError}
|
|
144
|
+
*/
|
|
145
|
+
export function buildDataFlowContext({
|
|
146
|
+
identity = null,
|
|
147
|
+
target,
|
|
148
|
+
site,
|
|
149
|
+
limits,
|
|
150
|
+
now = () => Date.now(),
|
|
151
|
+
correlationId,
|
|
152
|
+
} = {}) {
|
|
153
|
+
const id = correlationId || randomUUID();
|
|
154
|
+
const targetName = typeof target?.name === "string" ? target.name.trim() : "";
|
|
155
|
+
if (!targetName) {
|
|
156
|
+
throw new DataFlowBudgetError(REASON_BUDGET_CONTEXT_MISSING, id, "context");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sec = site?.security ?? {};
|
|
160
|
+
const clientId = typeof identity?.clientId === "string" && identity.clientId.trim()
|
|
161
|
+
? identity.clientId.trim()
|
|
162
|
+
: "local-operator";
|
|
163
|
+
const destination = sanitizeDeclaration(`${clientId}:${targetName}`);
|
|
164
|
+
const ceiling = sanitizeDeclaration(sec.declaredCeiling) ?? "internal";
|
|
165
|
+
const declaredCeiling = sanitizeDeclaration(sec.declaredCeiling);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
principalKey: principalBudgetKey(identity),
|
|
169
|
+
targetName,
|
|
170
|
+
destination,
|
|
171
|
+
ceiling: declaredCeiling ?? ceiling,
|
|
172
|
+
declaredCeiling,
|
|
173
|
+
correlationId: id,
|
|
174
|
+
limits: resolveBudgetLimits(limits ?? sec.readBudgets),
|
|
175
|
+
now,
|
|
176
|
+
currentRequest: { rows: 0, bytes: 0, counted: false },
|
|
177
|
+
// Connector-side counters bind to an authenticated inbound principal.
|
|
178
|
+
// Stdio / local operator still send declared headers; source uid budgets
|
|
179
|
+
// remain the floor there (see #178).
|
|
180
|
+
enforce: Boolean(identity),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {object} context
|
|
186
|
+
* @param {Function} fn
|
|
187
|
+
* @returns {*}
|
|
188
|
+
*/
|
|
189
|
+
export function runWithDataFlow(context, fn) {
|
|
190
|
+
return store.run(context, fn);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @returns {object|null}
|
|
195
|
+
*/
|
|
196
|
+
export function getDataFlowContext() {
|
|
197
|
+
return store.getStore() ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Headers for one northbound Drupal request. Empty when no context is bound.
|
|
202
|
+
* @param {object} [context]
|
|
203
|
+
* @returns {Object<string,string>}
|
|
204
|
+
*/
|
|
205
|
+
export function northboundHeaders(context) {
|
|
206
|
+
const ctx = context ?? getDataFlowContext();
|
|
207
|
+
if (!ctx?.targetName || !ctx.principalKey) return {};
|
|
208
|
+
const headers = new Map();
|
|
209
|
+
const ceiling = sanitizeDeclaration(ctx.declaredCeiling);
|
|
210
|
+
if (ceiling) headers.set(HEADER_DECLARED_CEILING, ceiling);
|
|
211
|
+
const destination = sanitizeDeclaration(ctx.destination);
|
|
212
|
+
if (destination) headers.set(HEADER_DECLARED_DESTINATION, destination);
|
|
213
|
+
return Object.fromEntries(headers);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {object|null} ctx
|
|
218
|
+
* @returns {object}
|
|
219
|
+
* @throws {DataFlowBudgetError}
|
|
220
|
+
*/
|
|
221
|
+
function requireBoundContext(ctx) {
|
|
222
|
+
if (!ctx?.principalKey || !ctx.targetName) {
|
|
223
|
+
throw new DataFlowBudgetError(
|
|
224
|
+
REASON_BUDGET_CONTEXT_MISSING,
|
|
225
|
+
ctx?.correlationId || randomUUID(),
|
|
226
|
+
"context",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return ctx;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {string} kind
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
function reasonFor(kind) {
|
|
237
|
+
switch (kind) {
|
|
238
|
+
case "page":
|
|
239
|
+
return REASON_PAGE;
|
|
240
|
+
case "bytes":
|
|
241
|
+
return REASON_RESPONSE_SIZE;
|
|
242
|
+
case "chained_action":
|
|
243
|
+
return REASON_CHAINED_ACTION;
|
|
244
|
+
case "request":
|
|
245
|
+
case "rows":
|
|
246
|
+
return REASON_READ;
|
|
247
|
+
default: {
|
|
248
|
+
const _exhaustive = kind;
|
|
249
|
+
return _exhaustive && REASON_READ;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} kind
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
function budgetClass(kind) {
|
|
259
|
+
switch (kind) {
|
|
260
|
+
case "chained_action":
|
|
261
|
+
return "chained_actions";
|
|
262
|
+
case "rows":
|
|
263
|
+
return "rows";
|
|
264
|
+
case "bytes":
|
|
265
|
+
return "bytes";
|
|
266
|
+
case "page":
|
|
267
|
+
return "pages";
|
|
268
|
+
case "request":
|
|
269
|
+
return "requests";
|
|
270
|
+
default: {
|
|
271
|
+
const _exhaustive = kind;
|
|
272
|
+
return _exhaustive || "requests";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Consume a budget class only when an inbound principal is bound.
|
|
279
|
+
* @param {"request"|"page"|"rows"|"bytes"|"chained_action"} kind
|
|
280
|
+
* @param {number} [amount]
|
|
281
|
+
* @param {{retry?: boolean}} [options]
|
|
282
|
+
* @returns {void}
|
|
283
|
+
*/
|
|
284
|
+
export function consumeBudgetIfEnforced(kind, amount = 1, options = {}) {
|
|
285
|
+
if (!getDataFlowContext()?.enforce) return;
|
|
286
|
+
consumeBudget(kind, amount, options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Account a successful northbound body against row and byte caps.
|
|
291
|
+
* @param {object|null} json
|
|
292
|
+
* @param {string} [text]
|
|
293
|
+
* @returns {void}
|
|
294
|
+
*/
|
|
295
|
+
export function accountNorthboundBody(json, text = "") {
|
|
296
|
+
if (!getDataFlowContext()?.enforce) return;
|
|
297
|
+
if (text) consumeBudget("bytes", Buffer.byteLength(text));
|
|
298
|
+
const rows = Array.isArray(json?.data) ? json.data.length : (json?.data ? 1 : 0);
|
|
299
|
+
if (rows) consumeBudget("rows", rows);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Consume one budget class against the request-scoped principal+target key.
|
|
304
|
+
*
|
|
305
|
+
* @param {"request"|"page"|"rows"|"bytes"|"chained_action"} kind
|
|
306
|
+
* @param {number} [amount]
|
|
307
|
+
* @param {{retry?: boolean}} [options]
|
|
308
|
+
* @returns {void}
|
|
309
|
+
* @throws {DataFlowBudgetError}
|
|
310
|
+
*/
|
|
311
|
+
export function consumeBudget(kind, amount = 1, options = {}) {
|
|
312
|
+
const ctx = requireBoundContext(getDataFlowContext());
|
|
313
|
+
const qty = typeof amount === "number" && amount > 0 ? amount : 1;
|
|
314
|
+
|
|
315
|
+
if (options.retry && (kind === "request" || kind === "page")) {
|
|
316
|
+
if (!ctx.currentRequest.counted) {
|
|
317
|
+
throw deny(ctx, kind);
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (kind === "rows" || kind === "bytes") {
|
|
323
|
+
const cap = kind === "rows" ? ctx.limits.results : ctx.limits.bytes;
|
|
324
|
+
const used = kind === "rows" ? ctx.currentRequest.rows : ctx.currentRequest.bytes;
|
|
325
|
+
if (used + qty > cap) {
|
|
326
|
+
throw deny(ctx, kind);
|
|
327
|
+
}
|
|
328
|
+
if (kind === "rows") ctx.currentRequest.rows += qty;
|
|
329
|
+
else ctx.currentRequest.bytes += qty;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!WINDOW_KINDS.has(kind)) {
|
|
334
|
+
throw deny(ctx, kind);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!windowConsume(ctx, kind, qty)) {
|
|
338
|
+
throw deny(ctx, kind);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (kind === "request") {
|
|
342
|
+
ctx.currentRequest = { rows: 0, bytes: 0, counted: true };
|
|
343
|
+
} else {
|
|
344
|
+
ctx.currentRequest.counted = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {object} ctx
|
|
350
|
+
* @param {string} kind
|
|
351
|
+
* @returns {DataFlowBudgetError}
|
|
352
|
+
*/
|
|
353
|
+
function deny(ctx, kind) {
|
|
354
|
+
return new DataFlowBudgetError(reasonFor(kind), ctx.correlationId, budgetClass(kind));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {object} ctx
|
|
359
|
+
* @param {string} kind
|
|
360
|
+
* @returns {{limit: number, windowMs: number}}
|
|
361
|
+
*/
|
|
362
|
+
function windowSpec(ctx, kind) {
|
|
363
|
+
if (kind === "page") {
|
|
364
|
+
return { limit: ctx.limits.pages, windowMs: ctx.limits.pageWindowSec * 1000 };
|
|
365
|
+
}
|
|
366
|
+
if (kind === "chained_action") {
|
|
367
|
+
return {
|
|
368
|
+
limit: ctx.limits.chainedActions,
|
|
369
|
+
windowMs: ctx.limits.chainedActionWindowSec * 1000,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return { limit: ctx.limits.requests, windowMs: ctx.limits.requestWindowSec * 1000 };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* @param {object} ctx
|
|
377
|
+
* @param {string} kind
|
|
378
|
+
* @returns {string}
|
|
379
|
+
*/
|
|
380
|
+
function windowKey(ctx, kind) {
|
|
381
|
+
return `${ctx.principalKey}::${ctx.targetName}::${kind}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @param {object} ctx
|
|
386
|
+
* @param {string} kind
|
|
387
|
+
* @param {number} qty
|
|
388
|
+
* @returns {boolean}
|
|
389
|
+
*/
|
|
390
|
+
function windowConsume(ctx, kind, qty) {
|
|
391
|
+
const { limit, windowMs } = windowSpec(ctx, kind);
|
|
392
|
+
const now = ctx.now();
|
|
393
|
+
const key = windowKey(ctx, kind);
|
|
394
|
+
let bucket = windows.get(key);
|
|
395
|
+
if (!bucket || now >= bucket.resetAt) {
|
|
396
|
+
bucket = { count: 0, resetAt: now + windowMs };
|
|
397
|
+
windows.set(key, bucket);
|
|
398
|
+
}
|
|
399
|
+
if (bucket.count + qty > limit) return false;
|
|
400
|
+
bucket.count += qty;
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Map a source-side refusal body onto a connector denial that carries our
|
|
406
|
+
* correlation id and none of the upstream payload.
|
|
407
|
+
*
|
|
408
|
+
* @param {string} detail
|
|
409
|
+
* @param {string} [correlationId]
|
|
410
|
+
* @returns {DataFlowBudgetError|null}
|
|
411
|
+
*/
|
|
412
|
+
export function sourceBudgetDenial(detail, correlationId) {
|
|
413
|
+
const text = String(detail ?? "");
|
|
414
|
+
const reason = SOURCE_BUDGET_REASONS.find((code) => text.includes(code));
|
|
415
|
+
if (!reason) return null;
|
|
416
|
+
const id = correlationId || getDataFlowContext()?.correlationId || randomUUID();
|
|
417
|
+
return new DataFlowBudgetError(reason, id, reason);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Drop in-memory windows. Tests only.
|
|
422
|
+
* @returns {void}
|
|
423
|
+
*/
|
|
424
|
+
export function resetDataFlowBudgets() {
|
|
425
|
+
windows.clear();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* JSON:API collection paths are type/bundle with no uuid segment.
|
|
430
|
+
* @param {string} path
|
|
431
|
+
* @returns {boolean}
|
|
432
|
+
*/
|
|
433
|
+
export function isCollectionJsonApiPath(path) {
|
|
434
|
+
const pathname = String(path ?? "").split("?")[0];
|
|
435
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
436
|
+
return parts[0] === "jsonapi" && parts.length === 3;
|
|
437
|
+
}
|
package/src/lib/dispatch.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
assertPrincipalEntitlement, callerTargetHints, getRequestIdentity,
|
|
21
21
|
} from "./principal.js";
|
|
22
22
|
import { assertExplicitSiteForWrite, withResolvedTarget } from "./site-target.js";
|
|
23
|
+
import { buildDataFlowContext, consumeBudgetIfEnforced, runWithDataFlow } from "./data-flow.js";
|
|
23
24
|
import { allHandlers } from "../tools/index.js";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -143,25 +144,45 @@ export async function securityMiddleware(toolName, args, handler, context = {})
|
|
|
143
144
|
|
|
144
145
|
const site = getSiteConfig(nextArgs.site);
|
|
145
146
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
const assertCallAllowed = async () => {
|
|
148
|
+
// Source-governance gate (#176). The diagnostic tools stay callable while
|
|
149
|
+
// governance fails — they are how an operator learns which condition failed.
|
|
150
|
+
if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
|
|
151
|
+
await assertSourceGovernance(site);
|
|
152
|
+
}
|
|
151
153
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
const sec = resolveSecurityConfig(site);
|
|
155
|
+
const op = inferOperation(toolName);
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
if (op === "delete") {
|
|
158
|
+
assertDestructiveAllowed(sec, extractEntityType(toolName, nextArgs), nextArgs?.id ?? "?");
|
|
159
|
+
assertNotReadOnly(sec, toolName);
|
|
160
|
+
} else if (op === "write") {
|
|
161
|
+
assertNotReadOnly(sec, toolName);
|
|
162
|
+
} else if (op === "graphql" && nextArgs?.query) {
|
|
163
|
+
assertGraphqlMutationAllowed(sec, nextArgs.query);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (!resolved) {
|
|
168
|
+
await assertCallAllowed();
|
|
169
|
+
return handler(nextArgs);
|
|
162
170
|
}
|
|
163
171
|
|
|
164
|
-
|
|
172
|
+
const flow = buildDataFlowContext({
|
|
173
|
+
identity,
|
|
174
|
+
target: { name: resolved.name, baseUrl: site.baseUrl, source: resolved.source },
|
|
175
|
+
site,
|
|
176
|
+
});
|
|
177
|
+
return runWithDataFlow(flow, async () => {
|
|
178
|
+
await assertCallAllowed();
|
|
179
|
+
// Charge only after governance and policy gates pass, so an outage or a
|
|
180
|
+
// local deny cannot exhaust the window. Diagnostics do not consume.
|
|
181
|
+
if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
|
|
182
|
+
consumeBudgetIfEnforced("chained_action");
|
|
183
|
+
}
|
|
184
|
+
return handler(nextArgs);
|
|
185
|
+
});
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
/**
|
package/src/lib/drupal-fetch.js
CHANGED
|
@@ -7,6 +7,13 @@ import { createReadStream, statSync } from "fs";
|
|
|
7
7
|
import { basename } from "path";
|
|
8
8
|
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
9
9
|
import { clearToken } from "./oauth.js";
|
|
10
|
+
import {
|
|
11
|
+
accountNorthboundBody,
|
|
12
|
+
consumeBudgetIfEnforced,
|
|
13
|
+
isCollectionJsonApiPath,
|
|
14
|
+
northboundHeaders,
|
|
15
|
+
sourceBudgetDenial,
|
|
16
|
+
} from "./data-flow.js";
|
|
10
17
|
import {
|
|
11
18
|
assertUploadPathAllowed,
|
|
12
19
|
sanitizeUploadFilename,
|
|
@@ -15,6 +22,25 @@ import {
|
|
|
15
22
|
|
|
16
23
|
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
17
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Read a 2xx body once. Prefers text() so byte accounting can see the payload;
|
|
27
|
+
* falls back to json() for test doubles that only implement that.
|
|
28
|
+
* @param {object} res node-fetch Response or test double.
|
|
29
|
+
* @returns {Promise<{json: object|null, text: string}>}
|
|
30
|
+
*/
|
|
31
|
+
async function readOkBody(res) {
|
|
32
|
+
if (typeof res.text === "function") {
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
if (!text) return { json: null, text: "" };
|
|
35
|
+
try {
|
|
36
|
+
return { json: JSON.parse(text), text };
|
|
37
|
+
} catch {
|
|
38
|
+
return { json: null, text };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { json: await res.json(), text: "" };
|
|
42
|
+
}
|
|
43
|
+
|
|
18
44
|
/**
|
|
19
45
|
* Standard JSON:API request against a site.
|
|
20
46
|
*
|
|
@@ -28,30 +54,38 @@ const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
|
28
54
|
*/
|
|
29
55
|
export async function drupalFetch(site, path, options = {}) {
|
|
30
56
|
const url = `${site.baseUrl}${path}`;
|
|
57
|
+
const collection = isCollectionJsonApiPath(path);
|
|
58
|
+
let paid = false;
|
|
31
59
|
|
|
32
|
-
async function attempt() {
|
|
60
|
+
async function attempt(isRetry = false) {
|
|
61
|
+
consumeBudgetIfEnforced("request", 1, { retry: isRetry || paid });
|
|
62
|
+
if (collection) consumeBudgetIfEnforced("page", 1, { retry: isRetry || paid });
|
|
63
|
+
paid = true;
|
|
33
64
|
return fetch(url, {
|
|
34
65
|
...options,
|
|
35
66
|
headers: {
|
|
36
67
|
"Content-Type": JSON_API_CONTENT_TYPE,
|
|
37
68
|
Accept: JSON_API_CONTENT_TYPE,
|
|
38
69
|
...clientHeaders(),
|
|
39
|
-
...(await authHeadersAsync(site)),
|
|
40
70
|
...(options.headers || {}),
|
|
71
|
+
...northboundHeaders(),
|
|
72
|
+
...(await authHeadersAsync(site)),
|
|
41
73
|
},
|
|
42
74
|
});
|
|
43
75
|
}
|
|
44
76
|
|
|
45
|
-
let res = await attempt();
|
|
77
|
+
let res = await attempt(false);
|
|
46
78
|
|
|
47
79
|
// OAuth sites: a 401 may mean the token expired server-side. Refresh once.
|
|
48
80
|
if (res.status === 401 && site.oauth) {
|
|
49
81
|
clearToken(site);
|
|
50
|
-
res = await attempt();
|
|
82
|
+
res = await attempt(true);
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
if (!res.ok) {
|
|
54
86
|
const body = await res.text();
|
|
87
|
+
const mapped = sourceBudgetDenial(body);
|
|
88
|
+
if (mapped) throw mapped;
|
|
55
89
|
let detail = body;
|
|
56
90
|
try {
|
|
57
91
|
const parsed = JSON.parse(body);
|
|
@@ -64,7 +98,9 @@ export async function drupalFetch(site, path, options = {}) {
|
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
if (res.status === 204) return null; // No Content (e.g. DELETE success)
|
|
67
|
-
|
|
101
|
+
const { json, text } = await readOkBody(res);
|
|
102
|
+
accountNorthboundBody(json, text);
|
|
103
|
+
return json;
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
/**
|
|
@@ -78,12 +114,14 @@ export async function drupalGraphqlFetch(site, body) {
|
|
|
78
114
|
const endpoint = site.graphqlEndpoint || "/graphql";
|
|
79
115
|
const url = `${site.baseUrl}${endpoint}`;
|
|
80
116
|
|
|
117
|
+
consumeBudgetIfEnforced("request");
|
|
81
118
|
const res = await fetch(url, {
|
|
82
119
|
method: "POST",
|
|
83
120
|
headers: {
|
|
84
121
|
"Content-Type": "application/json",
|
|
85
122
|
Accept: "application/json",
|
|
86
123
|
...clientHeaders(),
|
|
124
|
+
...northboundHeaders(),
|
|
87
125
|
...(await authHeadersAsync(site)),
|
|
88
126
|
},
|
|
89
127
|
body: JSON.stringify(body),
|
|
@@ -94,10 +132,14 @@ export async function drupalGraphqlFetch(site, body) {
|
|
|
94
132
|
// cached token so the next request re-acquires, then surface the error.
|
|
95
133
|
if (res.status === 401 && site.oauth) clearToken(site);
|
|
96
134
|
const text = await res.text();
|
|
135
|
+
const mapped = sourceBudgetDenial(text);
|
|
136
|
+
if (mapped) throw mapped;
|
|
97
137
|
throw new Error(`GraphQL request failed ${res.status}: ${text}`);
|
|
98
138
|
}
|
|
99
139
|
|
|
100
|
-
|
|
140
|
+
const { json, text } = await readOkBody(res);
|
|
141
|
+
accountNorthboundBody(json, text);
|
|
142
|
+
return json;
|
|
101
143
|
}
|
|
102
144
|
|
|
103
145
|
/**
|
|
@@ -130,6 +172,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
130
172
|
const stat = statSync(safePath);
|
|
131
173
|
const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
|
|
132
174
|
|
|
175
|
+
consumeBudgetIfEnforced("request");
|
|
133
176
|
const res = await fetch(url, {
|
|
134
177
|
method: "POST",
|
|
135
178
|
headers: {
|
|
@@ -137,6 +180,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
137
180
|
"Content-Disposition": `file; filename="${filename}"`,
|
|
138
181
|
Accept: JSON_API_CONTENT_TYPE,
|
|
139
182
|
...clientHeaders(),
|
|
183
|
+
...northboundHeaders(),
|
|
140
184
|
...(await authHeadersAsync(site)),
|
|
141
185
|
},
|
|
142
186
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
|
|
@@ -147,6 +191,8 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
147
191
|
|
|
148
192
|
if (!res.ok) {
|
|
149
193
|
const body = await res.text();
|
|
194
|
+
const mapped = sourceBudgetDenial(body);
|
|
195
|
+
if (mapped) throw mapped;
|
|
150
196
|
throw new Error(`File upload failed ${res.status}: ${body}`);
|
|
151
197
|
}
|
|
152
198
|
|