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.
- 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 +82 -0
- package/README.md +23 -12
- 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/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
- 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,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
|
+
}
|