drupal-mcp-connector 2.8.0 → 2.9.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/CHANGELOG.md +26 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/lib/contracts/approval.js +72 -0
- package/src/lib/contracts/decisions.js +139 -0
- package/src/lib/contracts/drupal.js +525 -0
- package/src/lib/contracts/evaluator.js +28 -0
- package/src/lib/contracts/evidence-sink.js +67 -0
- package/src/lib/contracts/fixtures.js +117 -0
- package/src/lib/contracts/index.js +46 -0
- package/src/lib/contracts/relay.js +83 -0
- package/src/lib/contracts/system-of-record.js +61 -0
- package/src/lib/contracts/types.js +242 -0
- package/src/lib/contracts/version.js +52 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drupal system-of-record adapter (#181).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the connector's existing security, principal, and (injected) backend
|
|
5
|
+
* seams so the published contracts can be proven against Drupal without a
|
|
6
|
+
* second adapter and without rewriting MCP tool dispatch.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_SECURITY_PRESET,
|
|
12
|
+
SecurityError,
|
|
13
|
+
assertConfigWriteAllowed,
|
|
14
|
+
assertDeleteAllowed,
|
|
15
|
+
assertPublishAllowed,
|
|
16
|
+
assertReadAllowed,
|
|
17
|
+
assertWriteAllowed,
|
|
18
|
+
isPublishBearing,
|
|
19
|
+
resolveSecurityConfig,
|
|
20
|
+
} from "../security.js";
|
|
21
|
+
import { createMemoryApproval } from "./approval.js";
|
|
22
|
+
import { composeDecisions, ContractError, REASON } from "./decisions.js";
|
|
23
|
+
import { createMemoryEvidenceSink, requiresEvidence } from "./evidence-sink.js";
|
|
24
|
+
import { createMemoryBackend } from "./fixtures.js";
|
|
25
|
+
import { createLocalRelay, hintTargetName } from "./relay.js";
|
|
26
|
+
import { bindApprovalForExecute, comparePostconditions } from "./system-of-record.js";
|
|
27
|
+
import {
|
|
28
|
+
DECISION_RESULTS,
|
|
29
|
+
createActionManifest,
|
|
30
|
+
createDecisionRecord,
|
|
31
|
+
createExecutionReceipt,
|
|
32
|
+
createIdentityContext,
|
|
33
|
+
} from "./types.js";
|
|
34
|
+
import {
|
|
35
|
+
ADAPTER_CONTRACT_POLICY_REVISION,
|
|
36
|
+
ADAPTER_CONTRACT_VERSION,
|
|
37
|
+
negotiateContractVersion,
|
|
38
|
+
} from "./version.js";
|
|
39
|
+
|
|
40
|
+
const CONTROL_ENTITY_TYPES = new Set([
|
|
41
|
+
"user_role",
|
|
42
|
+
"oauth2_token",
|
|
43
|
+
"key",
|
|
44
|
+
"consumer",
|
|
45
|
+
"encryption_profile",
|
|
46
|
+
"mcp_tool_config",
|
|
47
|
+
"mcp_policy_profile",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const HOSTILE_HTML = /<\s*script\b|javascript\s*:|on(error|load|click)\s*=/i;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object} [options]
|
|
54
|
+
* @returns {import("./system-of-record.js").SystemOfRecordAdapter}
|
|
55
|
+
*/
|
|
56
|
+
export function createDrupalAdapter(options = {}) {
|
|
57
|
+
const sites = Array.isArray(options.sites)
|
|
58
|
+
? options.sites
|
|
59
|
+
: (options.site ? [options.site] : []);
|
|
60
|
+
const defaultSite = options.site ?? sites[0];
|
|
61
|
+
const identity = options.identity
|
|
62
|
+
? createIdentityContext(options.identity)
|
|
63
|
+
: null;
|
|
64
|
+
const backend = options.backend ?? createMemoryBackend();
|
|
65
|
+
const approval = options.approval ?? createMemoryApproval();
|
|
66
|
+
const evidence = options.evidence ?? createMemoryEvidenceSink();
|
|
67
|
+
const relay = options.relay ?? createLocalRelay({
|
|
68
|
+
sites,
|
|
69
|
+
grants: options.grants ?? null,
|
|
70
|
+
defaultSite: defaultSite?._name,
|
|
71
|
+
});
|
|
72
|
+
const upstreamEvaluator = options.upstreamEvaluator ?? null;
|
|
73
|
+
const assuranceClass = options.assuranceClass ?? "boundary_enforced";
|
|
74
|
+
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
contractVersion: ADAPTER_CONTRACT_VERSION,
|
|
77
|
+
approval,
|
|
78
|
+
evidence,
|
|
79
|
+
backend,
|
|
80
|
+
relay,
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {object} proposal
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
mapAction(proposal) {
|
|
87
|
+
return mapDrupalAction(proposal);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} proposal
|
|
92
|
+
* @returns {object}
|
|
93
|
+
*/
|
|
94
|
+
propose(proposal) {
|
|
95
|
+
negotiateContractVersion(proposal?.contractVersion);
|
|
96
|
+
return createActionManifest({
|
|
97
|
+
...proposal,
|
|
98
|
+
actionClass: mapDrupalAction(proposal),
|
|
99
|
+
contractVersion: ADAPTER_CONTRACT_VERSION,
|
|
100
|
+
target: proposal.target ?? {
|
|
101
|
+
name: hintTargetName(proposal.hints) ?? defaultSite?._name,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {object} manifest
|
|
108
|
+
* @returns {object}
|
|
109
|
+
*/
|
|
110
|
+
evaluate(manifest) {
|
|
111
|
+
negotiateContractVersion(manifest.contractVersion);
|
|
112
|
+
const local = evaluateLocal(manifest, {
|
|
113
|
+
identity,
|
|
114
|
+
relay,
|
|
115
|
+
defaultSite,
|
|
116
|
+
sites,
|
|
117
|
+
});
|
|
118
|
+
const upstream = upstreamEvaluator
|
|
119
|
+
? upstreamEvaluator.evaluate(manifest, identity)
|
|
120
|
+
: null;
|
|
121
|
+
return composeDecisions(upstream, local);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Execute only after a fresh evaluation. A caller allow cannot widen
|
|
126
|
+
* a local deny or skip required approval. `decision.decisionId` is
|
|
127
|
+
* kept on the receipt when present.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} manifest
|
|
130
|
+
* @param {object} [decision]
|
|
131
|
+
* @param {{approvalId?: string}} [execOptions]
|
|
132
|
+
* @returns {Promise<object>}
|
|
133
|
+
*/
|
|
134
|
+
async execute(manifest, decision = {}, execOptions = {}) {
|
|
135
|
+
if (decision.actionDigest && decision.actionDigest !== manifest.digest) {
|
|
136
|
+
return createExecutionReceipt({
|
|
137
|
+
decisionId: decision.decisionId,
|
|
138
|
+
outcome: "failed",
|
|
139
|
+
reason: REASON.REPLAY,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const local = this.evaluate(manifest);
|
|
144
|
+
const caller = typedCallerDecision(decision);
|
|
145
|
+
const authoritative = composeDecisions(caller, local);
|
|
146
|
+
const decisionId = decision.decisionId ?? authoritative.decisionId;
|
|
147
|
+
|
|
148
|
+
if (authoritative.result === "deny") {
|
|
149
|
+
return createExecutionReceipt({
|
|
150
|
+
decisionId,
|
|
151
|
+
outcome: "denied",
|
|
152
|
+
reason: authoritative.reason,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
bindApprovalForExecute(
|
|
158
|
+
authoritative,
|
|
159
|
+
manifest,
|
|
160
|
+
approval,
|
|
161
|
+
execOptions.approvalId,
|
|
162
|
+
identity?.subject,
|
|
163
|
+
);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return createExecutionReceipt({
|
|
166
|
+
decisionId,
|
|
167
|
+
outcome: "failed",
|
|
168
|
+
reason: err instanceof ContractError ? err.reason : REASON.APPROVAL_REQUIRED,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const needsEvidence = requiresEvidence(manifest.actionClass, assuranceClass);
|
|
173
|
+
if (needsEvidence) {
|
|
174
|
+
try {
|
|
175
|
+
evidence.writeRequired(createExecutionReceipt({
|
|
176
|
+
decisionId,
|
|
177
|
+
outcome: "pending",
|
|
178
|
+
}));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return createExecutionReceipt({
|
|
181
|
+
decisionId,
|
|
182
|
+
outcome: "failed",
|
|
183
|
+
reason: err instanceof ContractError ? err.reason : REASON.EVIDENCE_WRITE_FAILED,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const snapshot = typeof backend.captureState === "function"
|
|
189
|
+
? await backend.captureState()
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
const written = await applyBackend(backend, manifest);
|
|
193
|
+
const observed = written
|
|
194
|
+
? await backend.getEntity({
|
|
195
|
+
id: written.id,
|
|
196
|
+
entityType: manifest.entityType,
|
|
197
|
+
bundle: manifest.bundle,
|
|
198
|
+
}) ?? written
|
|
199
|
+
: null;
|
|
200
|
+
const declared = declaredEffects(manifest);
|
|
201
|
+
const post = comparePostconditions(declared, flattenObserved(observed));
|
|
202
|
+
const receipt = createExecutionReceipt({
|
|
203
|
+
decisionId,
|
|
204
|
+
outcome: post.ok ? "ok" : "unknown",
|
|
205
|
+
reason: post.ok ? undefined : post.reason,
|
|
206
|
+
nativeActor: identity?.subject ?? "local-operator",
|
|
207
|
+
revisionId: observed?.id,
|
|
208
|
+
after: observed,
|
|
209
|
+
declaredEffects: declared,
|
|
210
|
+
observed: flattenObserved(observed),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (needsEvidence) {
|
|
214
|
+
try {
|
|
215
|
+
evidence.writeRequired(receipt);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
await restoreBackend(backend, snapshot);
|
|
218
|
+
return createExecutionReceipt({
|
|
219
|
+
...receipt,
|
|
220
|
+
receiptId: randomUUID(),
|
|
221
|
+
outcome: "failed",
|
|
222
|
+
reason: err instanceof ContractError ? err.reason : REASON.EVIDENCE_WRITE_FAILED,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
evidence.writeAdvisory(receipt);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return receipt;
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {object} proposal
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
export function mapDrupalAction(proposal = {}) {
|
|
239
|
+
const operation = String(proposal.operation ?? "read");
|
|
240
|
+
const entityType = String(proposal.entityType ?? "");
|
|
241
|
+
if (
|
|
242
|
+
operation === "config"
|
|
243
|
+
|| operation === "config_set"
|
|
244
|
+
|| operation === "config_get"
|
|
245
|
+
|| CONTROL_ENTITY_TYPES.has(entityType)
|
|
246
|
+
) {
|
|
247
|
+
return "control_plane";
|
|
248
|
+
}
|
|
249
|
+
if (operation === "delete" || operation === "publish" || isPublishBearing(proposal.attributes)) {
|
|
250
|
+
return "publish_or_destructive";
|
|
251
|
+
}
|
|
252
|
+
if (operation === "list" || operation === "export") {
|
|
253
|
+
return "exfiltration_read";
|
|
254
|
+
}
|
|
255
|
+
if (operation === "create" || operation === "update") {
|
|
256
|
+
return "reversible_write";
|
|
257
|
+
}
|
|
258
|
+
return "bounded_read";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {object} manifest
|
|
263
|
+
* @param {object} ctx
|
|
264
|
+
* @returns {object}
|
|
265
|
+
*/
|
|
266
|
+
function evaluateLocal(manifest, ctx) {
|
|
267
|
+
const base = {
|
|
268
|
+
actionDigest: manifest.digest,
|
|
269
|
+
actionClass: manifest.actionClass,
|
|
270
|
+
policyDigest: policyDigestFor(ctx.defaultSite),
|
|
271
|
+
policyRevision: ADAPTER_CONTRACT_POLICY_REVISION,
|
|
272
|
+
evaluatorVersion: ADAPTER_CONTRACT_VERSION,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const hostile = detectHostileInput(manifest);
|
|
276
|
+
if (hostile) {
|
|
277
|
+
return deny(base, hostile, manifest.target);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let resolved;
|
|
281
|
+
try {
|
|
282
|
+
resolved = ctx.relay.resolve(ctx.identity, manifest.hints ?? {});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const reason = err instanceof ContractError ? err.reason : REASON.TENANT_ESCAPE;
|
|
285
|
+
return deny(base, reason, manifest.target);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const site = resolved.site;
|
|
289
|
+
const evaluated = {
|
|
290
|
+
...base,
|
|
291
|
+
policyDigest: policyDigestFor(site),
|
|
292
|
+
};
|
|
293
|
+
const sec = resolveSecurityConfig(site);
|
|
294
|
+
const scope = requiredScope(manifest);
|
|
295
|
+
if (ctx.identity && scope && !ctx.identity.scopes.includes(scope)) {
|
|
296
|
+
return deny(evaluated, REASON.POLICY_DENIED, resolved, {
|
|
297
|
+
type: "scope",
|
|
298
|
+
scope,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
applySecurityGates(sec, manifest);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const reason = err instanceof SecurityError ? REASON.TARGET_DENIED : REASON.POLICY_DENIED;
|
|
306
|
+
return deny(evaluated, reason, resolved);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (manifest.actionClass === "publish_or_destructive" || manifest.actionClass === "control_plane") {
|
|
310
|
+
const writeLike = manifest.operation !== "config_get" && manifest.operation !== "read";
|
|
311
|
+
if (writeLike) {
|
|
312
|
+
return createDecisionRecord({
|
|
313
|
+
...evaluated,
|
|
314
|
+
result: "require_approval",
|
|
315
|
+
reason: REASON.APPROVAL_REQUIRED,
|
|
316
|
+
target: { name: resolved.name },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (manifest.actionClass === "exfiltration_read") {
|
|
322
|
+
return createDecisionRecord({
|
|
323
|
+
...evaluated,
|
|
324
|
+
result: "allow_with_obligations",
|
|
325
|
+
reason: "read_budget",
|
|
326
|
+
obligations: [{ type: "read_budget" }],
|
|
327
|
+
target: { name: resolved.name },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return createDecisionRecord({
|
|
332
|
+
...evaluated,
|
|
333
|
+
result: "allow",
|
|
334
|
+
reason: "allow",
|
|
335
|
+
target: { name: resolved.name },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param {object} sec
|
|
341
|
+
* @param {object} manifest
|
|
342
|
+
* @returns {void}
|
|
343
|
+
*/
|
|
344
|
+
function applySecurityGates(sec, manifest) {
|
|
345
|
+
const entityType = manifest.entityType;
|
|
346
|
+
const bundle = manifest.bundle;
|
|
347
|
+
const operation = manifest.operation;
|
|
348
|
+
|
|
349
|
+
if (operation === "config" || operation === "config_set") {
|
|
350
|
+
assertConfigWriteAllowed(sec);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (operation === "config_get") {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (operation === "delete") {
|
|
357
|
+
assertDeleteAllowed(sec, entityType, bundle, manifest.id ?? "");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (operation === "create" || operation === "update" || operation === "publish") {
|
|
361
|
+
assertWriteAllowed(sec, operation === "publish" ? "update" : operation, entityType, bundle);
|
|
362
|
+
assertPublishAllowed(sec, manifest.attributes ?? {});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (operation === "list" || operation === "export" || operation === "read") {
|
|
366
|
+
if (entityType) assertReadAllowed(sec, entityType, bundle);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* @param {object} manifest
|
|
372
|
+
* @returns {string|null}
|
|
373
|
+
*/
|
|
374
|
+
function detectHostileInput(manifest) {
|
|
375
|
+
const html = collectHtml(manifest);
|
|
376
|
+
if (html && HOSTILE_HTML.test(html)) return REASON.HOSTILE_INPUT;
|
|
377
|
+
if (manifest.filePath && isEscapingPath(manifest.filePath)) return REASON.HOSTILE_INPUT;
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {object} manifest
|
|
383
|
+
* @returns {string}
|
|
384
|
+
*/
|
|
385
|
+
function collectHtml(manifest) {
|
|
386
|
+
if (typeof manifest.html === "string") return manifest.html;
|
|
387
|
+
const body = manifest.attributes?.body;
|
|
388
|
+
if (typeof body === "string") return body;
|
|
389
|
+
if (body && typeof body === "object" && typeof body.value === "string") return body.value;
|
|
390
|
+
return "";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @param {string} filePath
|
|
395
|
+
* @returns {boolean}
|
|
396
|
+
*/
|
|
397
|
+
function isEscapingPath(filePath) {
|
|
398
|
+
const normalized = String(filePath).split("\\").join("/");
|
|
399
|
+
if (normalized.includes("..")) return true;
|
|
400
|
+
if (normalized.includes("/.ssh/") || normalized.endsWith("/.ssh")) return true;
|
|
401
|
+
if (normalized.includes("/.env") || normalized.split("/").pop()?.startsWith(".env")) return true;
|
|
402
|
+
if (normalized.startsWith("/etc/") || normalized === "/etc/passwd") return true;
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {object} manifest
|
|
408
|
+
* @returns {string|null}
|
|
409
|
+
*/
|
|
410
|
+
function requiredScope(manifest) {
|
|
411
|
+
if (manifest.actionClass === "control_plane") return "mcp_config";
|
|
412
|
+
if (
|
|
413
|
+
manifest.actionClass === "reversible_write"
|
|
414
|
+
|| manifest.actionClass === "publish_or_destructive"
|
|
415
|
+
) {
|
|
416
|
+
return "mcp_write";
|
|
417
|
+
}
|
|
418
|
+
return "mcp_read";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @param {object} site
|
|
423
|
+
* @returns {string}
|
|
424
|
+
*/
|
|
425
|
+
function policyDigestFor(site) {
|
|
426
|
+
const preset = site?.security?.preset ?? DEFAULT_SECURITY_PRESET;
|
|
427
|
+
return `${preset}:${ADAPTER_CONTRACT_POLICY_REVISION}`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* @param {object} base
|
|
432
|
+
* @param {string} reason
|
|
433
|
+
* @param {object} [target]
|
|
434
|
+
* @param {object} [challenge]
|
|
435
|
+
* @returns {object}
|
|
436
|
+
*/
|
|
437
|
+
function deny(base, reason, target, challenge) {
|
|
438
|
+
return createDecisionRecord({
|
|
439
|
+
...base,
|
|
440
|
+
result: "deny",
|
|
441
|
+
reason,
|
|
442
|
+
reasons: [reason],
|
|
443
|
+
target: target?.name ? { name: target.name } : target,
|
|
444
|
+
challenge,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @param {object} [decision]
|
|
450
|
+
* @returns {object|null}
|
|
451
|
+
*/
|
|
452
|
+
function typedCallerDecision(decision) {
|
|
453
|
+
if (!decision || typeof decision !== "object") return null;
|
|
454
|
+
if (!DECISION_RESULTS.includes(decision.result)) return null;
|
|
455
|
+
return decision;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* @param {object} backend
|
|
460
|
+
* @param {*} snapshot
|
|
461
|
+
* @returns {Promise<void>}
|
|
462
|
+
*/
|
|
463
|
+
async function restoreBackend(backend, snapshot) {
|
|
464
|
+
if (snapshot === undefined || snapshot === null || typeof backend.restoreState !== "function") {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
await backend.restoreState(snapshot);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {object} backend
|
|
472
|
+
* @param {object} manifest
|
|
473
|
+
* @returns {Promise<object|null>}
|
|
474
|
+
*/
|
|
475
|
+
async function applyBackend(backend, manifest) {
|
|
476
|
+
const ref = {
|
|
477
|
+
entityType: manifest.entityType,
|
|
478
|
+
bundle: manifest.bundle,
|
|
479
|
+
id: manifest.id,
|
|
480
|
+
attributes: { ...(manifest.attributes ?? {}) },
|
|
481
|
+
};
|
|
482
|
+
if (manifest.operation === "delete") {
|
|
483
|
+
await backend.deleteEntity(ref);
|
|
484
|
+
return { id: manifest.id, deleted: true };
|
|
485
|
+
}
|
|
486
|
+
if (manifest.operation === "create" || manifest.operation === "publish") {
|
|
487
|
+
if (manifest.operation === "publish") ref.attributes.status = true;
|
|
488
|
+
if (manifest.operation === "publish" && manifest.id) {
|
|
489
|
+
return backend.updateEntity(ref);
|
|
490
|
+
}
|
|
491
|
+
return backend.createEntity(ref);
|
|
492
|
+
}
|
|
493
|
+
if (manifest.operation === "update") {
|
|
494
|
+
return backend.updateEntity(ref);
|
|
495
|
+
}
|
|
496
|
+
if (manifest.id) {
|
|
497
|
+
return backend.getEntity(ref);
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @param {object} manifest
|
|
504
|
+
* @returns {object|undefined}
|
|
505
|
+
*/
|
|
506
|
+
function declaredEffects(manifest) {
|
|
507
|
+
if (manifest.expectedEffects) return { ...manifest.expectedEffects };
|
|
508
|
+
if (manifest.attributes && manifest.attributes.status !== undefined) {
|
|
509
|
+
return { status: manifest.attributes.status };
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* @param {object|null} entity
|
|
516
|
+
* @returns {object|undefined}
|
|
517
|
+
*/
|
|
518
|
+
function flattenObserved(entity) {
|
|
519
|
+
if (!entity) return undefined;
|
|
520
|
+
return {
|
|
521
|
+
id: entity.id,
|
|
522
|
+
status: entity.status ?? entity.attributes?.status,
|
|
523
|
+
deleted: entity.deleted,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy-evaluator contract (#181).
|
|
3
|
+
*
|
|
4
|
+
* An evaluator returns a typed DecisionRecord. It does not execute. Callers
|
|
5
|
+
* compose upstream and local decisions with composeDecisions — never by
|
|
6
|
+
* trusting an upstream allow as authority.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} PolicyEvaluator
|
|
11
|
+
* @property {(manifest: object, identity: object) => object} evaluate
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a function as a PolicyEvaluator.
|
|
16
|
+
* @param {(manifest: object, identity: object) => object} evaluateFn
|
|
17
|
+
* @returns {PolicyEvaluator}
|
|
18
|
+
*/
|
|
19
|
+
export function createEvaluator(evaluateFn) {
|
|
20
|
+
if (typeof evaluateFn !== "function") {
|
|
21
|
+
throw new TypeError("createEvaluator requires an evaluate function");
|
|
22
|
+
}
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
evaluate(manifest, identity) {
|
|
25
|
+
return evaluateFn(manifest, identity);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence-sink contract (#181).
|
|
3
|
+
*
|
|
4
|
+
* When policy or assurance class requires durable evidence, failure to write
|
|
5
|
+
* it fails the governed action. Advisory writes may degrade.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ContractError, REASON } from "./decisions.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} EvidenceSink
|
|
12
|
+
* @property {(receipt: object) => void} writeRequired
|
|
13
|
+
* @property {(receipt: object) => {ok: boolean, degraded?: boolean}} writeAdvisory
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* In-process evidence sink. `failRequired` makes every required write fail.
|
|
18
|
+
* `failFinalRequired` fails only the post-mutation receipt (outcome other
|
|
19
|
+
* than `pending`).
|
|
20
|
+
*
|
|
21
|
+
* @param {{failRequired?: boolean, failFinalRequired?: boolean}} [options]
|
|
22
|
+
* @returns {EvidenceSink & {records: object[]}}
|
|
23
|
+
*/
|
|
24
|
+
export function createMemoryEvidenceSink({
|
|
25
|
+
failRequired = false,
|
|
26
|
+
failFinalRequired = false,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const records = [];
|
|
29
|
+
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
records,
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} receipt
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
writeRequired(receipt) {
|
|
37
|
+
if (failRequired || (failFinalRequired && receipt?.outcome !== "pending")) {
|
|
38
|
+
throw new ContractError(
|
|
39
|
+
"Required evidence write failed.",
|
|
40
|
+
REASON.EVIDENCE_WRITE_FAILED,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
records.push({ required: true, receipt });
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} receipt
|
|
48
|
+
* @returns {{ok: boolean}}
|
|
49
|
+
*/
|
|
50
|
+
writeAdvisory(receipt) {
|
|
51
|
+
records.push({ required: false, receipt });
|
|
52
|
+
return { ok: true };
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether this action class must persist evidence at the given assurance.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} actionClass
|
|
61
|
+
* @param {string} assuranceClass
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function requiresEvidence(actionClass, assuranceClass) {
|
|
65
|
+
if (assuranceClass === "advisory") return false;
|
|
66
|
+
return actionClass === "publish_or_destructive" || actionClass === "control_plane";
|
|
67
|
+
}
|