drupal-mcp-connector 2.10.1 → 2.12.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 CHANGED
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.12.0] - 2026-09-02
11
+
12
+ ### Security
13
+ - **W&L-operated policy-bundle promotion on the relay edge (#253).** Optional
14
+ `auth.promotions` maps a SHA-256 digest to a sealed portable document plus
15
+ two distinct operator ids. Eligible documents fan down the tenant-agent
16
+ channel after hello; the agent presents them to local enforcement
17
+ (verify / activate / attest). When the table is present, non-diagnostic
18
+ `tools/call` requires the bound `auth.policies` digest **and** a matching
19
+ agent attestation. The edge never mints a Sentinel HMAC key. Omitting
20
+ `auth.promotions` keeps the digest-only path. Lab/loopback only — not
21
+ tenant self-service or a hosted-service claim.
22
+
23
+ ## [2.11.0] - 2026-09-02
24
+
25
+ ### Security
26
+ - **Expected policy digest on the relay edge (#250).** Optional
27
+ `auth.policies` maps inbound `sub` (then `azp`) to a SHA-256 digest.
28
+ When that table is present, non-diagnostic `tools/call` without a
29
+ mapping is `not_entitled` with no frames (including a call that omits
30
+ the tool name, and even when no agent is connected). The granted digest
31
+ is stamped on `identity.policy` and `correlation.policyDigest`. Caller
32
+ `policy` / `digest` arguments are ignored and stripped. Diagnostic
33
+ tools stay callable. Omitting `auth.policies` keeps the prior path.
34
+ Lab/loopback only — not dual-control promotion, bundle distribution, or
35
+ a hosted-service claim.
36
+ - **Principal → Drupal actor mapping on the relay edge (#247).** Optional
37
+ `auth.actors` maps inbound `sub` (then `azp`) to a Drupal user UUID. When
38
+ that table is present, write-like `tools/call` without a mapping is
39
+ `not_entitled` with no frames. The granted actor is stamped on
40
+ `identity.actor`; JWT `act.sub` is a confirming hint inside `delegators`
41
+ and becomes `identity.delegator`. Caller `actor` / `delegator` arguments
42
+ and spoofable identity headers are ignored and stripped from the framed
43
+ body. JSON:API writes attach `relationships.uid` from the grant. The
44
+ northbound JWT is never the southbound Drupal credential. Omitting
45
+ `auth.actors` keeps writes on the site OAuth consumer's owner. Lab/loopback
46
+ only — not a live revision-uid or public-URL claim.
47
+ - **Authoritative tenant routing on the relay edge (#244).** Optional
48
+ `auth.tenantGrants` maps inbound client id to tenant agent ids. When that
49
+ table is present, the edge selects the tenant from the grant (JWT `azp`),
50
+ not from caller `tenant` / `site` input. A hint for another tenant is
51
+ `not_entitled` with no frames on any tunnel. The granted tenant is stamped
52
+ on `identity.tenant` and on `correlation: { requestId, tenant, target,
53
+ source }`; the caller `tenant` argument is stripped from the framed body.
54
+ `drupal_list_sites` reports grant-sourced `tenants`. Installs without
55
+ `tenantGrants` keep the site-derived unique-agent path. Lab/loopback
56
+ token-resolved tenancy only — not a public hostname or path-prefix claim.
57
+ - **Two-tenant isolation on the relay edge (#242).** The edge keeps one
58
+ agent-channel session per `agentId` instead of replacing the previous
59
+ tenant. Channel records may bind `sites` (catalog names); that bind is
60
+ the tenant boundary. A second unscoped agent is denied `unbound_tenant`,
61
+ overlapping site claims are denied `overlapping_tenant`, and fan-down
62
+ selects the unique agent granted to the northbound principal. Cross-tenant
63
+ hints still fail closed before any frame reaches the wrong process. A
64
+ tenant disconnect rejects only that tenant's in-flight requests. A
65
+ spoofed `mcp-response` from another agent cannot complete the wait.
66
+ A hot-reload that changes a connected agent's `sites` bind drops that
67
+ tunnel on the next request (`no_agent`) instead of routing on the stale
68
+ hello snapshot.
69
+ Fan-down frames carry `{ requestId, tenant, target, source }` correlation
70
+ and still never carry site secrets, the channel token, or the northbound JWT. A single
71
+ unscoped agent remains the previous compatibility path. Lab/loopback
72
+ proof only — not a hosted-service or public-URL claim.
73
+
10
74
  ## [2.10.1] - 2026-08-28
11
75
 
12
76
  ### Fixed
@@ -1329,6 +1393,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1329
1393
  - User tools gained explicit PII-access assertions.
1330
1394
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1331
1395
 
1396
+ [2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
1397
+ [2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
1332
1398
  [2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
1333
1399
  [1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
1334
1400
  [0.10.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.10.0
@@ -16,7 +16,10 @@
16
16
  * MCP_CHANNEL_CREDENTIALS_FILE
17
17
  * Agent channel credential store (or config
18
18
  * relay.channelCredentialsFile). Required. JSON:
19
- * {"agents": {"<id>": {"tokenSha256": "<hex>"}}}
19
+ * {"agents": {"<id>": {"tokenSha256": "<hex>",
20
+ * "sites": ["site-name"]}}}. `sites` binds the tunnel
21
+ * to catalog names (the tenant boundary). Omit `sites`
22
+ * only for a single-agent install.
20
23
  * MCP_EDGE_PORT Northbound port (default: MCP_PORT / config tls.port).
21
24
  * MCP_EDGE_AGENT_PORT
22
25
  * Agent channel port (default: northbound port + 1).
@@ -31,12 +34,19 @@
31
34
  * primary entry point).
32
35
  *
33
36
  * Config: auth.grants (client id -> [site names]) is mandatory; the edge
34
- * refuses to start without it.
37
+ * refuses to start without it. Optional auth.tenantGrants (client id ->
38
+ * [tenant agent ids]) makes tenant routing grant-authoritative. Optional
39
+ * auth.actors (sub / azp -> Drupal user UUID) maps the inbound principal
40
+ * to a Drupal actor for write-like tools. Optional auth.policies
41
+ * (sub / azp -> SHA-256 digest) is the expected signed policy on the edge.
42
+ * Optional auth.promotions (digest -> sealed document + two operator ids)
43
+ * is the W&L-operated dual-control ledger; the edge fans eligible bundles
44
+ * to the tenant agent and requires a matching local attestation.
35
45
  */
36
46
 
37
47
  import { readFileSync } from "node:fs";
38
48
  import process from "node:process";
39
- import { getInboundGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
49
+ import { getInboundActors, getInboundGrants, getInboundPolicies, getInboundPromotions, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
40
50
  import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
41
51
  import { createRateLimiter } from "../src/lib/rate-limit.js";
42
52
  import {
@@ -121,6 +131,10 @@ try {
121
131
  edge = await startEdge({
122
132
  auth: inboundCfg,
123
133
  grants,
134
+ tenantGrants: getInboundTenantGrants(),
135
+ actors: getInboundActors(),
136
+ policies: getInboundPolicies(),
137
+ promotions: getInboundPromotions(),
124
138
  sites,
125
139
  defaultSite: config.defaultSite,
126
140
  channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
@@ -32,6 +32,18 @@
32
32
  "grants": {
33
33
  "_comment": "Optional. Map inbound OAuth client_id (azp) to site names, e.g. \"content-agent\": [\"production\", \"staging\"]. When the map names any client, unknown clients receive no sites. Omit this object (or leave only this comment) to let every resolvable site stay visible; inbound scopes still gate the tool surface."
34
34
  },
35
+ "tenantGrants": {
36
+ "_comment": "Optional on the library path; used by drupal-mcp-edge for token-resolved tenancy. Map inbound client_id (azp) to tenant agent ids, e.g. \"content-agent\": [\"tenant-a\"]. Caller tenant/site arguments are hints checked against this grant, never authority. Omit to derive the tenant from the unique agent covering the site grant."
37
+ },
38
+ "actors": {
39
+ "_comment": "Optional. Map inbound sub or client_id (azp) to a Drupal user UUID for write authorship, e.g. \"content-agent\": { \"uuid\": \"11111111-1111-4111-8111-111111111111\", \"delegators\": [\"operator-1\"] }. When present, write-like tools/call without a mapping are not_entitled. JWT act.sub is a confirming hint inside delegators. Caller actor/delegator arguments are never authority. Omit to keep writes on the site OAuth consumer's owner account."
40
+ },
41
+ "policies": {
42
+ "_comment": "Optional. Map inbound sub or client_id (azp) to a SHA-256 digest of the expected signed Sentinel policy bundle, e.g. \"content-agent\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\". When present, non-diagnostic tools/call without a mapping are not_entitled. Caller policy/digest arguments are never authority. Omit to keep the prior path (no digest required at the edge). Local verify/activate stays on mcp_sentinel."
43
+ },
44
+ "promotions": {
45
+ "_comment": "Optional W&L-operated dual-control ledger. Map a SHA-256 digest to { document, approvals } where document is the sealed portable Sentinel bundle (claims + digest + hmac-sha256 seal) and approvals is two distinct operator ids. When present, the edge fans eligible documents to the tenant agent; non-diagnostic tools/call require a matching local attestation. The edge never mints a seal. Omit to keep the digest-only path. Tenant self-service is not this table."
46
+ },
35
47
  "revocationFile": "",
36
48
  "introspectionUrl": "",
37
49
  "introspectionClientIdEnv": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
11
+ import { getRequestIdentity } from "../principal.js";
11
12
  import { validateUuid, validateMachineName } from "../validate.js";
12
13
  import { parseFieldConfigObject } from "../field-definition.js";
13
14
  import { Backend } from "./backend-interface.js";
@@ -134,8 +135,34 @@ function applyFilter(params, { field, op = "eq", value }) {
134
135
  }
135
136
 
136
137
  /**
137
- * Read/write Backend adapter backed by Drupal core JSON:API.
138
+ * Bind JSON:API `uid` from the grant-stamped identity. Caller uid is overwritten.
139
+ * User entities are left unchanged. No actor claim (auth.actors not in effect
140
+ * for this principal) leaves relationships as-is — the prior, pre-DEV-123 path.
141
+ * A *present* actor claim that fails UUID validation fails closed (throws)
142
+ * rather than silently keeping a caller-supplied uid: resolveActor()/
143
+ * normalizeActors() already validate the shape before stamping identity.actor,
144
+ * so this should be unreachable in the normal path — it exists so a future
145
+ * bug upstream of this call can never downgrade into "trust the caller".
146
+ *
147
+ * @param {string} entityType
148
+ * @param {object|undefined} relationships
149
+ * @returns {object|undefined}
138
150
  */
151
+ export function grantActorUid(entityType, relationships) {
152
+ if (entityType === "user") return relationships;
153
+ const uuid = getRequestIdentity()?.actor;
154
+ if (typeof uuid !== "string") return relationships;
155
+ validateUuid(uuid, "actor");
156
+ const base = relationships && typeof relationships === "object" && !Array.isArray(relationships)
157
+ ? relationships
158
+ : {};
159
+ return {
160
+ ...base,
161
+ uid: { data: { type: "user--user", id: uuid } },
162
+ };
163
+ }
164
+
165
+ /** Read/write Backend adapter backed by Drupal core JSON:API. */
139
166
  export class JsonApiBackend extends Backend {
140
167
  /** @param {object} site Site config (must include `_name`). */
141
168
  constructor(site) {
@@ -337,7 +364,8 @@ export class JsonApiBackend extends Backend {
337
364
  async createEntity({ entityType, bundle, attributes = {}, relationships }) {
338
365
  const buildPayload = (attrs) => {
339
366
  const payload = { data: { type: `${entityType}--${bundle}`, attributes: attrs } };
340
- if (relationships) payload.data.relationships = relationships;
367
+ const rels = grantActorUid(entityType, relationships);
368
+ if (rels) payload.data.relationships = rels;
341
369
  return payload;
342
370
  };
343
371
  const data = await this.writeWithModerationFallback(this.resourcePath(entityType, bundle), "POST", buildPayload, attributes);
@@ -357,7 +385,8 @@ export class JsonApiBackend extends Backend {
357
385
  validateUuid(id);
358
386
  const buildPayload = (attrs) => {
359
387
  const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
360
- if (relationships) payload.data.relationships = relationships;
388
+ const rels = grantActorUid(entityType, relationships);
389
+ if (rels) payload.data.relationships = rels;
361
390
  return payload;
362
391
  };
363
392
  let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
package/src/lib/config.js CHANGED
@@ -244,6 +244,70 @@ export function getInboundGrants() {
244
244
  return entries.length ? Object.fromEntries(entries) : null;
245
245
  }
246
246
 
247
+ /**
248
+ * Server-resolved inbound tenant grants keyed by OAuth client id.
249
+ * When present, the edge routes by tenant agent id, never by caller input.
250
+ * A missing or empty map means tenant is derived from site grants (compat).
251
+ * @returns {object|null}
252
+ */
253
+ export function getInboundTenantGrants() {
254
+ const grants = loadConfig().auth?.tenantGrants;
255
+ if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
256
+ const entries = Object.entries(grants)
257
+ .filter(([clientId, tenants]) => !clientId.startsWith("_") && Array.isArray(tenants))
258
+ .map(([clientId, tenants]) => [
259
+ clientId,
260
+ [...new Set(
261
+ tenants.map((id) => String(id).trim()).filter((id) => id && !id.startsWith("_")),
262
+ )],
263
+ ]);
264
+ return entries.length ? Object.fromEntries(entries) : null;
265
+ }
266
+
267
+ /**
268
+ * Server-resolved inbound actor map keyed by OAuth `sub` or client id.
269
+ * When present, write-like tools require a mapped Drupal user UUID.
270
+ * @returns {object|null}
271
+ */
272
+ export function getInboundActors() {
273
+ const actors = loadConfig().auth?.actors;
274
+ if (!actors || typeof actors !== "object" || Array.isArray(actors)) return null;
275
+ const entries = Object.entries(actors)
276
+ .map(([key, value]) => [key.trim(), value])
277
+ .filter(([key]) => key && !key.startsWith("_"));
278
+ return entries.length ? Object.fromEntries(entries) : null;
279
+ }
280
+
281
+ /**
282
+ * Server-resolved inbound policy map keyed by OAuth `sub` or client id.
283
+ * When present, non-diagnostic tools/call require a mapped SHA-256 digest.
284
+ * @returns {object|null}
285
+ */
286
+ export function getInboundPolicies() {
287
+ const policies = loadConfig().auth?.policies;
288
+ if (!policies || typeof policies !== "object" || Array.isArray(policies)) return null;
289
+ const entries = Object.entries(policies)
290
+ .map(([key, value]) => [key.trim(), value])
291
+ .filter(([key]) => key && !key.startsWith("_"));
292
+ return entries.length ? Object.fromEntries(entries) : null;
293
+ }
294
+
295
+ /**
296
+ * W&L-operated dual-control promotion ledger keyed by SHA-256 digest.
297
+ * When present, the edge fans eligible sealed documents to the tenant agent.
298
+ * @returns {object|null}
299
+ */
300
+ export function getInboundPromotions() {
301
+ const promotions = loadConfig().auth?.promotions;
302
+ if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
303
+ return null;
304
+ }
305
+ const entries = Object.entries(promotions)
306
+ .map(([key, value]) => [key.trim(), value])
307
+ .filter(([key]) => key && !key.startsWith("_"));
308
+ return entries.length ? Object.fromEntries(entries) : null;
309
+ }
310
+
247
311
  // ---------------------------------------------------------------------------
248
312
  // Auth headers — never logged, never exposed in tool responses
249
313
  // ---------------------------------------------------------------------------
@@ -68,7 +68,7 @@ export function scopesFromClaim(scope) {
68
68
  /**
69
69
  * Freeze a request identity from validated claims. Caller headers are not an input.
70
70
  * @param {object} claims
71
- * @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string}>}
71
+ * @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string, actSub: ?string}>}
72
72
  */
73
73
  export function buildIdentity(claims) {
74
74
  const scopes = scopesFromClaim(claims.scope ?? claims.scp);
@@ -95,6 +95,10 @@ export function buildIdentity(claims) {
95
95
  && (claims.client_id === undefined || claims.client_id === null)
96
96
  ? null
97
97
  : String(claims.azp ?? claims.client_id),
98
+ actSub: (claims.act && typeof claims.act === "object" && !Array.isArray(claims.act)
99
+ && typeof claims.act.sub === "string" && claims.act.sub.trim())
100
+ ? claims.act.sub.trim()
101
+ : null,
98
102
  });
99
103
  }
100
104
 
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Tenant-side local policy enforcement (#253 / DEV-125).
3
+ *
4
+ * Loopback stand-in for mcp_sentinel `McpPolicyBundleRegistry`: mint / verify
5
+ * / activate / simulate / revoke / rollback / emergency deny. The relay edge
6
+ * never receives the signing key and never calls mint. A missing key cannot
7
+ * mint, verify, or activate — emergency deny still arms a deny floor without
8
+ * minting new authority.
9
+ */
10
+
11
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
12
+ import { SEAL_PREFIX } from "./policy-promotion.js";
13
+
14
+ export const POLICY_BUNDLE_VERSION = 1;
15
+
16
+ export const DEFAULT_BUNDLE_TTL = 86400 * 30;
17
+
18
+ export const EMERGENCY_DENY = "*";
19
+
20
+ export const EMERGENCY_DIGEST = "emergency-deny";
21
+
22
+ function normalize(value) {
23
+ if (value === null || typeof value === "string" || typeof value === "number"
24
+ || typeof value === "boolean") {
25
+ return value;
26
+ }
27
+ if (Array.isArray(value)) return value.map(normalize);
28
+ if (value && typeof value === "object") {
29
+ const source = new Map(Object.entries(value));
30
+ const bag = new Map();
31
+ for (const key of [...source.keys()].sort()) {
32
+ bag.set(key, normalize(source.get(key)));
33
+ }
34
+ return Object.fromEntries(bag);
35
+ }
36
+ throw new TypeError("Policy bundle claims may only contain scalars, lists and maps.");
37
+ }
38
+
39
+ /**
40
+ * Canonical JSON of claims (HMAC / digest input). Maps are key-sorted;
41
+ * lists keep caller order. Matches mcp_sentinel `McpPolicyBundle::canonicalJson`.
42
+ *
43
+ * @param {object} claims
44
+ * @returns {string}
45
+ */
46
+ export function canonicalJson(claims) {
47
+ return JSON.stringify(normalize(claims));
48
+ }
49
+
50
+ /**
51
+ * Hex SHA-256 of the canonical claims.
52
+ *
53
+ * @param {object} claims
54
+ * @returns {string}
55
+ */
56
+ export function digestOf(claims) {
57
+ return createHash("sha256").update(canonicalJson(claims)).digest("hex");
58
+ }
59
+
60
+ function safeEqual(left, right) {
61
+ if (typeof left !== "string" || typeof right !== "string") return false;
62
+ const a = Buffer.from(left);
63
+ const b = Buffer.from(right);
64
+ if (a.length !== b.length) return false;
65
+ return timingSafeEqual(a, b);
66
+ }
67
+
68
+ function deniedOperationsOf(claims) {
69
+ const denials = new Map(Object.entries(claims ?? {})).get("denials");
70
+ if (!denials || typeof denials !== "object" || Array.isArray(denials)) return [];
71
+ const ops = new Map(Object.entries(denials)).get("operations");
72
+ if (!Array.isArray(ops)) return [];
73
+ return ops.map(String);
74
+ }
75
+
76
+ function bundleFrom(claims, digest, seal) {
77
+ return Object.freeze({
78
+ claims,
79
+ digest,
80
+ seal,
81
+ version: () => Number(new Map(Object.entries(claims)).get("v") ?? 0),
82
+ expires: () => Number(new Map(Object.entries(claims)).get("expires") ?? 0),
83
+ deniedOperations: () => deniedOperationsOf(claims),
84
+ denies: (operation) => deniedOperationsOf(claims).includes(operation),
85
+ isExpired: (now) => {
86
+ const expires = Number(new Map(Object.entries(claims)).get("expires") ?? 0);
87
+ return expires > 0 && now >= expires;
88
+ },
89
+ toArray: () => ({ ...claims, digest, seal }),
90
+ });
91
+ }
92
+
93
+ /**
94
+ * In-process local enforcement used by the tenant agent in the loopback lab.
95
+ *
96
+ * @param {object} [options]
97
+ * @param {string|null} [options.signingKey] HMAC material. Null = disconnected.
98
+ * @param {() => number} [options.now] Unix seconds.
99
+ * @returns {object}
100
+ */
101
+ export function createLocalPolicyEnforcement({
102
+ signingKey = null,
103
+ now = () => Math.floor(Date.now() / 1000),
104
+ } = {}) {
105
+ let active = null;
106
+ let lastGood = null;
107
+ const revoked = new Map();
108
+
109
+ function canSeal() {
110
+ return typeof signingKey === "string" && signingKey.length > 0;
111
+ }
112
+
113
+ function isRevoked(digest) {
114
+ return revoked.has(digest);
115
+ }
116
+
117
+ function mint(deniedOperations = [], ttl = DEFAULT_BUNDLE_TTL) {
118
+ if (!canSeal()) return null;
119
+ const issued = now();
120
+ const unique = [...new Set((Array.isArray(deniedOperations) ? deniedOperations : [])
121
+ .map(String))];
122
+ const claims = {
123
+ denials: { operations: unique },
124
+ expires: issued + (ttl ?? DEFAULT_BUNDLE_TTL),
125
+ id: randomUUID(),
126
+ issued,
127
+ v: POLICY_BUNDLE_VERSION,
128
+ };
129
+ const digest = digestOf(claims);
130
+ const seal = SEAL_PREFIX + createHmac("sha256", signingKey).update(digest).digest("hex");
131
+ return bundleFrom(claims, digest, seal);
132
+ }
133
+
134
+ function verify(document) {
135
+ if (!canSeal()) return null;
136
+ if (!document || typeof document !== "object" || Array.isArray(document)) return null;
137
+ const bag = new Map(Object.entries(document));
138
+ const seal = bag.get("seal");
139
+ const claimedDigest = bag.get("digest");
140
+ bag.delete("seal");
141
+ bag.delete("digest");
142
+ const claims = Object.fromEntries(bag);
143
+ if (typeof seal !== "string" || !seal.startsWith(SEAL_PREFIX)) return null;
144
+ if (Number(new Map(Object.entries(claims)).get("v") ?? 0) !== POLICY_BUNDLE_VERSION) {
145
+ return null;
146
+ }
147
+ const digest = digestOf(claims);
148
+ if (!safeEqual(digest, typeof claimedDigest === "string" ? claimedDigest : "")) return null;
149
+ const expected = SEAL_PREFIX
150
+ + createHmac("sha256", signingKey).update(digest).digest("hex");
151
+ if (!safeEqual(expected, seal)) return null;
152
+ const bundle = bundleFrom(claims, digest, seal);
153
+ if (bundle.isExpired(now())) return null;
154
+ if (isRevoked(digest)) return null;
155
+ return bundle;
156
+ }
157
+
158
+ function attestation() {
159
+ return active;
160
+ }
161
+
162
+ function activeDigest() {
163
+ const digest = active && typeof active.digest === "string" ? active.digest : null;
164
+ return digest || null;
165
+ }
166
+
167
+ function emergencyDeny() {
168
+ if (active && typeof active.digest === "string") lastGood = active;
169
+ active = {
170
+ digest: EMERGENCY_DIGEST,
171
+ activated_at: now(),
172
+ previous: active && typeof active.digest === "string" ? active.digest : null,
173
+ emergency: true,
174
+ bundle: {
175
+ v: POLICY_BUNDLE_VERSION,
176
+ denials: { operations: [EMERGENCY_DENY] },
177
+ expires: 0,
178
+ id: EMERGENCY_DIGEST,
179
+ issued: now(),
180
+ },
181
+ };
182
+ }
183
+
184
+ function activateBundle(bundle) {
185
+ if (!canSeal() || !bundle) return null;
186
+ if (active && typeof active.digest === "string") lastGood = active;
187
+ const previous = active && typeof active.digest === "string" ? active.digest : null;
188
+ active = {
189
+ digest: bundle.digest,
190
+ activated_at: now(),
191
+ previous,
192
+ bundle: bundle.toArray(),
193
+ };
194
+ return {
195
+ digest: bundle.digest,
196
+ activated_at: active.activated_at,
197
+ previous,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Verify then activate a portable document. This is the agent hook.
203
+ *
204
+ * @param {object} document
205
+ * @returns {{ok: boolean, digest?: string, attested?: boolean, reason?: string}}
206
+ */
207
+ function activate(document) {
208
+ const bundle = verify(document);
209
+ if (!bundle) return { ok: false, reason: "unverified" };
210
+ const result = activateBundle(bundle);
211
+ if (!result) return { ok: false, reason: "cannot_activate" };
212
+ return { ok: true, digest: result.digest, attested: true };
213
+ }
214
+
215
+ function simulate(operation, localDenies, candidate = null) {
216
+ const digest = candidate?.digest ?? activeDigest();
217
+ if (localDenies) {
218
+ return { allow: false, reason: "local_deny", digest };
219
+ }
220
+ if (candidate === null && active && active.emergency) {
221
+ return {
222
+ allow: false,
223
+ reason: "emergency_deny",
224
+ digest: digest ?? EMERGENCY_DIGEST,
225
+ };
226
+ }
227
+ let bundle = candidate;
228
+ if (bundle === null) {
229
+ const document = active?.bundle ?? null;
230
+ bundle = document ? verify(document) : null;
231
+ if (bundle === null && digest) {
232
+ return { allow: false, reason: "bundle_unverified", digest };
233
+ }
234
+ }
235
+ if (bundle && (bundle.denies(operation) || bundle.denies(EMERGENCY_DENY))) {
236
+ return { allow: false, reason: "bundle_deny", digest: bundle.digest };
237
+ }
238
+ return { allow: true, reason: "allow", digest };
239
+ }
240
+
241
+ function revoke(digest) {
242
+ if (typeof digest !== "string" || !digest) return;
243
+ revoked.set(digest, now());
244
+ if (activeDigest() === digest) emergencyDeny();
245
+ }
246
+
247
+ function rollback() {
248
+ if (!lastGood || typeof lastGood.digest !== "string") return null;
249
+ if (isRevoked(lastGood.digest)) return null;
250
+ active = lastGood;
251
+ return lastGood;
252
+ }
253
+
254
+ return {
255
+ canSeal,
256
+ mint,
257
+ verify,
258
+ activate,
259
+ activateBundle,
260
+ attestation,
261
+ activeDigest,
262
+ simulate,
263
+ revoke,
264
+ rollback,
265
+ emergencyDeny,
266
+ isRevoked,
267
+ };
268
+ }