drupal-mcp-connector 2.7.4 → 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.
Files changed (152) 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 +82 -0
  122. package/README.md +23 -12
  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/contracts/approval.js +72 -0
  131. package/src/lib/contracts/decisions.js +139 -0
  132. package/src/lib/contracts/drupal.js +525 -0
  133. package/src/lib/contracts/evaluator.js +28 -0
  134. package/src/lib/contracts/evidence-sink.js +67 -0
  135. package/src/lib/contracts/fixtures.js +117 -0
  136. package/src/lib/contracts/index.js +46 -0
  137. package/src/lib/contracts/relay.js +83 -0
  138. package/src/lib/contracts/system-of-record.js +61 -0
  139. package/src/lib/contracts/types.js +242 -0
  140. package/src/lib/contracts/version.js +52 -0
  141. package/src/lib/data-flow.js +437 -0
  142. package/src/lib/dispatch.js +36 -15
  143. package/src/lib/drupal-fetch.js +52 -6
  144. package/src/lib/field-definition.js +285 -0
  145. package/src/lib/patch-preflight.js +157 -66
  146. package/src/lib/security.js +14 -0
  147. package/src/lib/server-tools.js +9 -0
  148. package/src/lib/write-revision.js +82 -2
  149. package/src/tools/bulk.js +4 -3
  150. package/src/tools/entities.js +11 -7
  151. package/src/tools/index.js +3 -2
  152. package/src/tools/nodes.js +55 -43
@@ -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
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * In-process contract fixtures (#181).
3
+ *
4
+ * These implement relay / approval / evidence-sink / backend roles for the
5
+ * Drupal conformance kit. They are not a second system-of-record adapter.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { createMemoryApproval } from "./approval.js";
10
+ import { createMemoryEvidenceSink } from "./evidence-sink.js";
11
+ import { createLocalRelay } from "./relay.js";
12
+
13
+ export { createMemoryApproval, createMemoryEvidenceSink, createLocalRelay };
14
+
15
+ /**
16
+ * In-memory backend used by the Drupal adapter during conformance.
17
+ *
18
+ * @param {object} [options]
19
+ * @param {object[]} [options.entities]
20
+ * @param {object} [options.mismatch] Forced observed fields after write.
21
+ * @returns {object}
22
+ */
23
+ export function createMemoryBackend({ entities = [], mismatch } = {}) {
24
+ const store = new Map();
25
+ for (const entity of entities) {
26
+ store.set(entity.id, { ...entity });
27
+ }
28
+
29
+ return Object.freeze({
30
+ store,
31
+ /**
32
+ * @param {{id: string}} ref
33
+ * @returns {Promise<object|null>}
34
+ */
35
+ async getEntity(ref) {
36
+ return store.get(ref.id) ?? null;
37
+ },
38
+
39
+ /**
40
+ * @param {object} input
41
+ * @returns {Promise<object>}
42
+ */
43
+ async createEntity(input) {
44
+ const id = input.id || randomUUID();
45
+ const attributes = { ...(input.attributes ?? {}) };
46
+ const entity = applyMismatch({
47
+ id,
48
+ entityType: input.entityType,
49
+ bundle: input.bundle,
50
+ status: attributes.status,
51
+ attributes,
52
+ }, mismatch);
53
+ store.set(id, entity);
54
+ return entity;
55
+ },
56
+
57
+ /**
58
+ * @param {object} input
59
+ * @returns {Promise<object>}
60
+ */
61
+ async updateEntity(input) {
62
+ const prev = store.get(input.id) ?? { id: input.id };
63
+ const attributes = { ...(prev.attributes ?? {}), ...(input.attributes ?? {}) };
64
+ const entity = applyMismatch({
65
+ ...prev,
66
+ entityType: input.entityType ?? prev.entityType,
67
+ bundle: input.bundle ?? prev.bundle,
68
+ status: attributes.status ?? prev.status,
69
+ attributes,
70
+ }, mismatch);
71
+ store.set(input.id, entity);
72
+ return entity;
73
+ },
74
+
75
+ /**
76
+ * @param {{id: string}} ref
77
+ * @returns {Promise<void>}
78
+ */
79
+ async deleteEntity(ref) {
80
+ store.delete(ref.id);
81
+ },
82
+
83
+ /**
84
+ * @returns {Promise<Map<string, object>>}
85
+ */
86
+ async captureState() {
87
+ return new Map(
88
+ [...store.entries()].map(([id, entity]) => [id, structuredClone(entity)]),
89
+ );
90
+ },
91
+
92
+ /**
93
+ * @param {Map<string, object>} snapshot
94
+ * @returns {Promise<void>}
95
+ */
96
+ async restoreState(snapshot) {
97
+ store.clear();
98
+ for (const [id, entity] of snapshot) {
99
+ store.set(id, structuredClone(entity));
100
+ }
101
+ },
102
+ });
103
+ }
104
+
105
+ /**
106
+ * @param {object} entity
107
+ * @param {object|undefined} mismatch
108
+ * @returns {object}
109
+ */
110
+ function applyMismatch(entity, mismatch) {
111
+ if (!mismatch) return entity;
112
+ const next = { ...entity, ...mismatch };
113
+ if (mismatch.status !== undefined) {
114
+ next.attributes = { ...(entity.attributes ?? {}), status: mismatch.status };
115
+ }
116
+ return next;
117
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Public adapter-contract surface (#181).
3
+ *
4
+ * Provider-neutral evaluator, relay, approval, evidence-sink, and
5
+ * system-of-record contracts, plus the Drupal reference adapter. Model and
6
+ * agent vendors are not part of this module.
7
+ */
8
+
9
+ export {
10
+ ADAPTER_CONTRACT_POLICY_REVISION,
11
+ ADAPTER_CONTRACT_VERSION,
12
+ negotiateContractVersion,
13
+ } from "./version.js";
14
+
15
+ export {
16
+ ACTION_CLASSES,
17
+ ASSURANCE_CLASSES,
18
+ DECISION_RESULTS,
19
+ assertActionClass,
20
+ assertAssuranceClass,
21
+ assertDecisionResult,
22
+ createActionManifest,
23
+ createDecisionRecord,
24
+ createExecutionReceipt,
25
+ createIdentityContext,
26
+ createObligation,
27
+ digestPayload,
28
+ stableStringify,
29
+ } from "./types.js";
30
+
31
+ export {
32
+ ContractError,
33
+ REASON,
34
+ VENDOR_FIELD_NAMES,
35
+ assertNoVendorFields,
36
+ composeDecisions,
37
+ unionObligations,
38
+ } from "./decisions.js";
39
+
40
+ export { createEvaluator } from "./evaluator.js";
41
+ export { createLocalRelay, hintTargetName } from "./relay.js";
42
+ export { createMemoryApproval } from "./approval.js";
43
+ export { createMemoryEvidenceSink, requiresEvidence } from "./evidence-sink.js";
44
+ export { bindApprovalForExecute, comparePostconditions } from "./system-of-record.js";
45
+ export { createMemoryBackend } from "./fixtures.js";
46
+ export { createDrupalAdapter, mapDrupalAction } from "./drupal.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tenant-relay contract (#181).
3
+ *
4
+ * Resolves the authoritative target for a principal. Caller-supplied site,
5
+ * tenant, environment, or target fields are hints. They never become
6
+ * authority. Vendor tunnel implementations stay outside this contract.
7
+ */
8
+
9
+ import { resolveAuthoritativeTarget } from "../principal.js";
10
+ import { ContractError, REASON } from "./decisions.js";
11
+
12
+ /**
13
+ * @typedef {Object} TenantRelay
14
+ * @property {(identity: object|null, hints?: object) => {site: object, source: string, name: string}} resolve
15
+ */
16
+
17
+ /**
18
+ * Local, vendor-neutral relay over configured sites.
19
+ *
20
+ * @param {object} params
21
+ * @param {Array<object>} params.sites
22
+ * @param {object|null} [params.grants]
23
+ * @param {string} [params.defaultSite]
24
+ * @returns {TenantRelay}
25
+ */
26
+ export function createLocalRelay({ sites, grants = null, defaultSite } = {}) {
27
+ const catalog = Array.isArray(sites) ? sites : [];
28
+
29
+ return Object.freeze({
30
+ /**
31
+ * @param {object|null} identity
32
+ * @param {object} [hints]
33
+ * @returns {{site: object, source: string, name: string}}
34
+ */
35
+ resolve(identity, hints = {}) {
36
+ if (!identity) {
37
+ const hintName = hintTargetName(hints);
38
+ if (hintName) {
39
+ const site = catalog.find((entry) => entry._name === hintName);
40
+ if (!site) {
41
+ throw new ContractError(
42
+ "Caller hint is not an authoritative target.",
43
+ REASON.TENANT_ESCAPE,
44
+ );
45
+ }
46
+ return { site, source: "hint", name: site._name };
47
+ }
48
+ const fallback = catalog.find((entry) => entry._name === defaultSite) || catalog[0];
49
+ if (!fallback) {
50
+ throw new ContractError("No target configured.", REASON.TENANT_ESCAPE);
51
+ }
52
+ return { site: fallback, source: "default", name: fallback._name };
53
+ }
54
+
55
+ try {
56
+ return resolveAuthoritativeTarget(hints, identity, catalog, {
57
+ grants,
58
+ defaultSite,
59
+ });
60
+ } catch (err) {
61
+ throw new ContractError(
62
+ err instanceof Error ? err.message : "Tenant escape.",
63
+ REASON.TENANT_ESCAPE,
64
+ );
65
+ }
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * First caller hint that can name a target. Order matches
72
+ * `resolveAuthoritativeTarget`: site, then target, environment, tenant.
73
+ *
74
+ * @param {object} [hints]
75
+ * @returns {string|undefined}
76
+ */
77
+ export function hintTargetName(hints) {
78
+ for (const key of ["site", "target", "environment", "tenant"]) {
79
+ const value = new Map(Object.entries(hints ?? {})).get(key);
80
+ if (typeof value === "string" && value.trim()) return value.trim();
81
+ }
82
+ return undefined;
83
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * System-of-record adapter contract (#181).
3
+ *
4
+ * One adapter maps native operations onto portable action classes, proposes
5
+ * an immutable manifest, evaluates it, and executes only after the typed
6
+ * decision (and any required approval) allows it. A second adapter is out
7
+ * of scope for this package.
8
+ */
9
+
10
+ import { ContractError, REASON } from "./decisions.js";
11
+
12
+ /**
13
+ * @typedef {Object} SystemOfRecordAdapter
14
+ * @property {(proposal: object) => string} mapAction
15
+ * @property {(proposal: object) => object} propose
16
+ * @property {(manifest: object) => object} evaluate
17
+ * @property {(manifest: object, decision: object, options?: object) => Promise<object>} execute
18
+ */
19
+
20
+ /**
21
+ * Consume a one-use approval when the decision requires it.
22
+ *
23
+ * @param {object} decision
24
+ * @param {object} manifest
25
+ * @param {{consume: Function}} [approval]
26
+ * @param {string} [approvalId]
27
+ * @param {string} [actor]
28
+ * @returns {{blocked: boolean}}
29
+ * @throws {ContractError}
30
+ */
31
+ export function bindApprovalForExecute(decision, manifest, approval, approvalId, actor) {
32
+ if (decision.result === "deny") return { blocked: true };
33
+ if (decision.result !== "require_approval") return { blocked: false };
34
+ if (!approval) {
35
+ throw new ContractError("Approval required.", REASON.APPROVAL_REQUIRED);
36
+ }
37
+ approval.consume(approvalId, manifest.digest, actor);
38
+ return { blocked: false };
39
+ }
40
+
41
+ /**
42
+ * Compare declared effects with observed target state.
43
+ *
44
+ * @param {object|undefined} declared
45
+ * @param {object|null|undefined} observed
46
+ * @returns {{ok: boolean, reason?: string}}
47
+ */
48
+ export function comparePostconditions(declared, observed) {
49
+ if (!declared || typeof declared !== "object") return { ok: true };
50
+ if (!observed || typeof observed !== "object") {
51
+ return { ok: false, reason: REASON.POSTCONDITION };
52
+ }
53
+ const declaredMap = new Map(Object.entries(declared));
54
+ const observedMap = new Map(Object.entries(observed));
55
+ for (const [key, expected] of declaredMap.entries()) {
56
+ if (observedMap.get(key) !== expected) {
57
+ return { ok: false, reason: REASON.POSTCONDITION };
58
+ }
59
+ }
60
+ return { ok: true };
61
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Typed records at adapter-contract boundaries (#181).
3
+ *
4
+ * Identity, decision, and receipt are independently owned. They share ids
5
+ * but an upstream identity or allow cannot become authority mid-flight.
6
+ */
7
+
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { ADAPTER_CONTRACT_VERSION } from "./version.js";
10
+ import { assertNoVendorFields } from "./decisions.js";
11
+
12
+ /** Portable action classes every system-of-record adapter must map onto. */
13
+ export const ACTION_CLASSES = Object.freeze([
14
+ "bounded_read",
15
+ "exfiltration_read",
16
+ "reversible_write",
17
+ "publish_or_destructive",
18
+ "control_plane",
19
+ ]);
20
+
21
+ const ACTION_CLASS_SET = new Set(ACTION_CLASSES);
22
+
23
+ /** Typed decision results. */
24
+ export const DECISION_RESULTS = Object.freeze([
25
+ "deny",
26
+ "allow",
27
+ "allow_with_obligations",
28
+ "require_approval",
29
+ ]);
30
+
31
+ const DECISION_RESULT_SET = new Set(DECISION_RESULTS);
32
+
33
+ /** Assurance classes. Never presented as equivalent. */
34
+ export const ASSURANCE_CLASSES = Object.freeze([
35
+ "source_enforced",
36
+ "boundary_enforced",
37
+ "advisory",
38
+ ]);
39
+
40
+ const ASSURANCE_CLASS_SET = new Set(ASSURANCE_CLASSES);
41
+
42
+ const ACTION_CLASS_MAP = new Map(ACTION_CLASSES.map((name) => [name, name]));
43
+ const DECISION_RESULT_MAP = new Map(DECISION_RESULTS.map((name) => [name, name]));
44
+ const ASSURANCE_CLASS_MAP = new Map(ASSURANCE_CLASSES.map((name) => [name, name]));
45
+
46
+ /**
47
+ * @param {string} value
48
+ * @returns {string}
49
+ */
50
+ export function assertActionClass(value) {
51
+ if (!ACTION_CLASS_SET.has(value)) {
52
+ throw new TypeError(`Unknown action class: ${String(value)}`);
53
+ }
54
+ return ACTION_CLASS_MAP.get(value);
55
+ }
56
+
57
+ /**
58
+ * @param {string} value
59
+ * @returns {string}
60
+ */
61
+ export function assertDecisionResult(value) {
62
+ if (!DECISION_RESULT_SET.has(value)) {
63
+ throw new TypeError(`Unknown decision result: ${String(value)}`);
64
+ }
65
+ return DECISION_RESULT_MAP.get(value);
66
+ }
67
+
68
+ /**
69
+ * @param {string} value
70
+ * @returns {string}
71
+ */
72
+ export function assertAssuranceClass(value) {
73
+ if (!ASSURANCE_CLASS_SET.has(value)) {
74
+ throw new TypeError(`Unknown assurance class: ${String(value)}`);
75
+ }
76
+ return ASSURANCE_CLASS_MAP.get(value);
77
+ }
78
+
79
+ /**
80
+ * Canonical JSON for digesting a manifest. Key order is sorted.
81
+ * @param {*} value
82
+ * @returns {string}
83
+ */
84
+ export function stableStringify(value) {
85
+ if (value === undefined) return "null";
86
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
87
+ if (Array.isArray(value)) {
88
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
89
+ }
90
+ const keys = Object.keys(value).sort();
91
+ const body = keys
92
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(new Map(Object.entries(value)).get(key))}`)
93
+ .join(",");
94
+ return `{${body}}`;
95
+ }
96
+
97
+ /**
98
+ * @param {object} payload
99
+ * @returns {string}
100
+ */
101
+ export function digestPayload(payload) {
102
+ return createHash("sha256").update(stableStringify(payload)).digest("hex");
103
+ }
104
+
105
+ /**
106
+ * @param {object} [raw]
107
+ * @returns {object}
108
+ */
109
+ export function createIdentityContext(raw = {}) {
110
+ assertNoVendorFields(raw);
111
+ const scopes = Array.isArray(raw.scopes) ? raw.scopes.map(String) : [];
112
+ return Object.freeze({
113
+ issuer: typeof raw.issuer === "string" ? raw.issuer : "local",
114
+ subject: typeof raw.subject === "string"
115
+ ? raw.subject
116
+ : (typeof raw.sub === "string" ? raw.sub : "local-operator"),
117
+ clientId: typeof raw.clientId === "string" ? raw.clientId : "local-operator",
118
+ tenant: typeof raw.tenant === "string" ? raw.tenant : undefined,
119
+ audience: typeof raw.audience === "string" ? raw.audience : undefined,
120
+ scopes: Object.freeze(scopes),
121
+ authTime: raw.authTime,
122
+ tokenId: typeof raw.tokenId === "string" ? raw.tokenId : undefined,
123
+ environment: typeof raw.environment === "string" ? raw.environment : undefined,
124
+ });
125
+ }
126
+
127
+ /**
128
+ * @param {{type: string, value?: string}} raw
129
+ * @returns {{type: string, value?: string}}
130
+ */
131
+ export function createObligation(raw) {
132
+ if (!raw || typeof raw.type !== "string" || !raw.type.trim()) {
133
+ throw new TypeError("Obligation requires a type");
134
+ }
135
+ return Object.freeze({ type: raw.type, value: raw.value });
136
+ }
137
+
138
+ /**
139
+ * @param {object} raw
140
+ * @returns {object}
141
+ */
142
+ export function createDecisionRecord(raw) {
143
+ assertNoVendorFields(raw);
144
+ const result = assertDecisionResult(raw.result);
145
+ const obligations = Array.isArray(raw.obligations)
146
+ ? raw.obligations.map((item) => createObligation(item))
147
+ : [];
148
+ return Object.freeze({
149
+ decisionId: raw.decisionId || randomUUID(),
150
+ result,
151
+ reason: raw.reason,
152
+ reasons: Object.freeze(Array.isArray(raw.reasons) ? raw.reasons : (raw.reason ? [raw.reason] : [])),
153
+ obligations: Object.freeze(obligations),
154
+ actionDigest: raw.actionDigest,
155
+ target: raw.target ? Object.freeze({ name: raw.target.name }) : undefined,
156
+ actionClass: raw.actionClass ? assertActionClass(raw.actionClass) : undefined,
157
+ policyDigest: raw.policyDigest,
158
+ policyRevision: raw.policyRevision,
159
+ evaluatorVersion: raw.evaluatorVersion || ADAPTER_CONTRACT_VERSION,
160
+ expiresAt: raw.expiresAt,
161
+ challenge: raw.challenge ? Object.freeze({ ...raw.challenge }) : undefined,
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Build an immutable action manifest. The digest is always computed from
167
+ * the canonical payload — a caller-supplied digest is ignored.
168
+ *
169
+ * @param {object} raw
170
+ * @returns {object}
171
+ */
172
+ export function createActionManifest(raw) {
173
+ assertNoVendorFields(raw);
174
+ const actionClass = assertActionClass(raw.actionClass);
175
+ const attributes = raw.attributes && typeof raw.attributes === "object"
176
+ ? { ...raw.attributes }
177
+ : {};
178
+ assertNoVendorFields(attributes);
179
+ const expectedEffects = raw.expectedEffects && typeof raw.expectedEffects === "object"
180
+ ? { ...raw.expectedEffects }
181
+ : undefined;
182
+ const target = raw.target && typeof raw.target === "object"
183
+ ? Object.freeze({ name: raw.target.name, version: raw.target.version })
184
+ : undefined;
185
+ if (raw.hints) assertNoVendorFields(raw.hints);
186
+ const digest = digestPayload({
187
+ actionClass,
188
+ operation: raw.operation,
189
+ entityType: raw.entityType,
190
+ bundle: raw.bundle,
191
+ id: raw.id,
192
+ attributes,
193
+ expectedEffects,
194
+ target,
195
+ tenant: raw.tenant,
196
+ });
197
+ return Object.freeze({
198
+ manifestId: raw.manifestId || randomUUID(),
199
+ digest,
200
+ contractVersion: raw.contractVersion || ADAPTER_CONTRACT_VERSION,
201
+ actionClass,
202
+ operation: raw.operation,
203
+ entityType: raw.entityType,
204
+ bundle: raw.bundle,
205
+ id: raw.id,
206
+ attributes: Object.freeze(attributes),
207
+ expectedEffects: expectedEffects ? Object.freeze(expectedEffects) : undefined,
208
+ target,
209
+ tenant: raw.tenant,
210
+ environment: raw.environment,
211
+ expiry: raw.expiry,
212
+ idempotencyKey: raw.idempotencyKey,
213
+ hints: raw.hints ? Object.freeze({ ...raw.hints }) : undefined,
214
+ filePath: raw.filePath,
215
+ html: raw.html,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * @param {object} raw
221
+ * @returns {object}
222
+ */
223
+ export function createExecutionReceipt(raw) {
224
+ assertNoVendorFields(raw);
225
+ const outcomes = new Set(["ok", "denied", "failed", "unknown", "pending"]);
226
+ if (!outcomes.has(raw.outcome)) {
227
+ throw new TypeError(`Unknown receipt outcome: ${String(raw.outcome)}`);
228
+ }
229
+ return Object.freeze({
230
+ receiptId: raw.receiptId || randomUUID(),
231
+ decisionId: raw.decisionId,
232
+ outcome: raw.outcome,
233
+ reason: raw.reason,
234
+ nativeActor: raw.nativeActor,
235
+ revisionId: raw.revisionId,
236
+ before: raw.before,
237
+ after: raw.after,
238
+ declaredEffects: raw.declaredEffects,
239
+ observed: raw.observed,
240
+ occurredAt: raw.occurredAt || new Date().toISOString(),
241
+ });
242
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Adapter-contract version and negotiation (#181).
3
+ *
4
+ * The family of evaluator / relay / approval / evidence-sink / system-of-record
5
+ * contracts is versioned independently of the npm package and of the
6
+ * connector↔Sentinel integration contract. Same major is compatible; a
7
+ * different major is a hard miss.
8
+ */
9
+
10
+ import { ContractError, REASON } from "./decisions.js";
11
+
12
+ /** Published adapter-contract version. */
13
+ export const ADAPTER_CONTRACT_VERSION = "1.0";
14
+
15
+ /** Policy revision label minted by this connector's local evaluator. */
16
+ export const ADAPTER_CONTRACT_POLICY_REVISION = "connector-local";
17
+
18
+ /**
19
+ * @param {string|undefined|null} requested Requested contract version.
20
+ * @returns {string} The version this process will speak.
21
+ * @throws {ContractError} when the requested major is not this major.
22
+ */
23
+ export function negotiateContractVersion(requested) {
24
+ if (requested === undefined || requested === null || requested === "") {
25
+ return ADAPTER_CONTRACT_VERSION;
26
+ }
27
+ if (typeof requested !== "string" || !isVersionString(requested)) {
28
+ throw new ContractError(
29
+ `Unsupported contract version request: ${String(requested)}`,
30
+ REASON.INCOMPATIBLE_CONTRACT,
31
+ );
32
+ }
33
+ const requestedMajor = requested.split(".")[0];
34
+ const supportedMajor = ADAPTER_CONTRACT_VERSION.split(".")[0];
35
+ if (requestedMajor !== supportedMajor) {
36
+ throw new ContractError(
37
+ `incompatible_contract_version: requested ${requested}, supported ${ADAPTER_CONTRACT_VERSION}`,
38
+ REASON.INCOMPATIBLE_CONTRACT,
39
+ );
40
+ }
41
+ return ADAPTER_CONTRACT_VERSION;
42
+ }
43
+
44
+ /**
45
+ * @param {string} value
46
+ * @returns {boolean}
47
+ */
48
+ function isVersionString(value) {
49
+ const parts = value.split(".");
50
+ if (!parts.length) return false;
51
+ return parts.every((part) => part.length > 0 && /^[0-9]+$/.test(part));
52
+ }