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.
Files changed (141) hide show
  1. package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
  3. package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
  6. package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
  7. package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
  8. package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
  9. package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
  10. package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
  11. package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
  13. package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
  16. package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
  18. package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
  19. package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
  20. package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
  21. package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
  23. package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
  24. package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
  25. package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
  26. package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
  27. package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
  28. package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
  29. package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
  30. package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
  31. package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
  33. package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
  34. package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
  35. package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
  36. package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
  37. package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
  39. package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
  40. package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
  43. package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
  44. package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
  45. package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
  46. package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
  49. package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
  50. package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
  51. package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
  52. package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
  54. package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
  55. package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
  57. package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
  58. package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
  59. package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
  60. package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
  62. package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
  65. package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
  68. package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
  69. package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
  70. package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
  71. package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
  72. package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
  73. package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
  74. package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
  76. package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
  77. package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
  78. package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
  79. package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
  80. package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
  81. package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
  82. package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
  84. package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
  85. package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
  87. package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
  88. package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
  89. package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
  90. package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
  91. package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
  92. package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
  93. package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
  95. package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
  96. package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
  97. package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
  98. package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
  99. package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
  100. package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
  101. package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
  102. package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
  103. package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
  107. package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
  108. package/{.claude → .agents}/commands/drupal-search.md +3 -4
  109. package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
  110. package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
  111. package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
  112. package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
  114. package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
  117. package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
  118. package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
  121. package/CHANGELOG.md +56 -0
  122. package/README.md +21 -11
  123. package/config/config.example.json +1 -1
  124. package/package.json +4 -2
  125. package/scripts/generate-commands.js +42 -21
  126. package/scripts/install-commands.js +157 -0
  127. package/src/lib/backends/backend-interface.js +20 -1
  128. package/src/lib/backends/jsonapi.js +47 -4
  129. package/src/lib/body-summary.js +60 -0
  130. package/src/lib/data-flow.js +437 -0
  131. package/src/lib/dispatch.js +36 -15
  132. package/src/lib/drupal-fetch.js +52 -6
  133. package/src/lib/field-definition.js +285 -0
  134. package/src/lib/patch-preflight.js +157 -66
  135. package/src/lib/security.js +14 -0
  136. package/src/lib/server-tools.js +9 -0
  137. package/src/lib/write-revision.js +82 -2
  138. package/src/tools/bulk.js +4 -3
  139. package/src/tools/entities.js +11 -7
  140. package/src/tools/index.js +3 -2
  141. 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
+ }
@@ -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
- // Source-governance gate (#176). The diagnostic tools stay callable while
147
- // governance fails they are how an operator learns which condition failed.
148
- if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
149
- await assertSourceGovernance(site);
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
- const sec = resolveSecurityConfig(site);
153
- const op = inferOperation(toolName);
154
+ const sec = resolveSecurityConfig(site);
155
+ const op = inferOperation(toolName);
154
156
 
155
- if (op === "delete") {
156
- assertDestructiveAllowed(sec, extractEntityType(toolName, nextArgs), nextArgs?.id ?? "?");
157
- assertNotReadOnly(sec, toolName);
158
- } else if (op === "write") {
159
- assertNotReadOnly(sec, toolName);
160
- } else if (op === "graphql" && nextArgs?.query) {
161
- assertGraphqlMutationAllowed(sec, nextArgs.query);
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
- return handler(nextArgs);
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
  /**
@@ -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
- return res.json();
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
- return res.json();
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