drupal-mcp-connector 2.13.1 → 2.14.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.
@@ -0,0 +1,607 @@
1
+ /**
2
+ * Independently verifiable evidence (#261).
3
+ *
4
+ * Reconciles one governed execution by stable identifiers, submits the
5
+ * minimized digest to an independent notary, and exports a tenant-scoped
6
+ * assessor pack bound to the live policy digest. Caller tenant / identity
7
+ * fields are never authority. Prompts, payloads, and secrets never enter
8
+ * the ledger or the export.
9
+ *
10
+ * Lab/loopback only. Not a hosted-service or design-partner admission claim.
11
+ * Audit Chain NDJSON is the off-system stream, not this anchor.
12
+ */
13
+
14
+ import { digestPayload } from "./contracts/types.js";
15
+ import { loadPinnedPublicKey, verifyInclusion } from "./anchor.js";
16
+ import { POLICY_DIGEST } from "./policy-promotion.js";
17
+
18
+ /** Explicit absences are identifiers, not missing fields. */
19
+ export const ABSENT = Object.freeze({
20
+ delegation: "none:no_delegation",
21
+ obligations: "none:no_obligations",
22
+ approval: "none:not_required",
23
+ targetRevision: "none:not_observed",
24
+ localExecution: "none:not_dispatched",
25
+ });
26
+
27
+ /** The eight identifiers a governed execution must carry. */
28
+ export const EXECUTION_IDS = Object.freeze([
29
+ "identityId",
30
+ "delegationId",
31
+ "decisionId",
32
+ "obligationId",
33
+ "approvalId",
34
+ "localExecutionId",
35
+ "targetRevision",
36
+ "receiptId",
37
+ ]);
38
+
39
+ export const RECONCILE_STATES = Object.freeze([
40
+ "settled",
41
+ "incomplete",
42
+ "mismatched",
43
+ ]);
44
+
45
+ const DEFAULT_MAX_RECORDS = 10_000;
46
+
47
+ const FORBIDDEN_EXPORT_KEYS = new Set([
48
+ "prompt",
49
+ "payload",
50
+ "email",
51
+ "mail",
52
+ "ip",
53
+ "useragent",
54
+ "user-agent",
55
+ "body",
56
+ "arguments",
57
+ "secret",
58
+ "token",
59
+ "password",
60
+ "authorization",
61
+ "cookie",
62
+ "clientsecret",
63
+ ]);
64
+
65
+ /**
66
+ * Controls the assessor may cite. Prior labs are residual in this export
67
+ * unless this pack carries evidence for them. Never "passed".
68
+ */
69
+ export const ASSESSOR_CONTROL_CATALOG = Object.freeze([
70
+ Object.freeze({ id: "P5.3", title: "Tenant secret isolation", source: "prior_lab" }),
71
+ Object.freeze({ id: "P5.5", title: "Token-resolved routing", source: "prior_lab" }),
72
+ Object.freeze({ id: "P5.4", title: "Signed policy lifecycle", source: "prior_lab" }),
73
+ Object.freeze({ id: "P8.5", title: "Local policy continuity", source: "prior_lab" }),
74
+ Object.freeze({ id: "P5.6", title: "Attributable usage", source: "prior_lab" }),
75
+ Object.freeze({ id: "P8.7", title: "Independent evidence anchoring", source: "anchor" }),
76
+ Object.freeze({ id: "P8.8", title: "Data-minimized provenance", source: "anchor" }),
77
+ Object.freeze({ id: "P8.10", title: "Execution reconciliation", source: "anchor" }),
78
+ Object.freeze({ id: "P9.8", title: "Evidence-producing onboarding", source: "onboard" }),
79
+ Object.freeze({ id: "P9.9", title: "Clean offboarding", source: "offboard" }),
80
+ ]);
81
+
82
+ function isRecord(value) {
83
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
84
+ }
85
+
86
+ function cleanKey(value) {
87
+ const key = typeof value === "string" ? value.trim() : "";
88
+ return key && !key.startsWith("_") ? key : "";
89
+ }
90
+
91
+ function grantIds(values) {
92
+ if (!Array.isArray(values)) return [];
93
+ return [...new Set(values.map((value) => cleanKey(value)).filter(Boolean))];
94
+ }
95
+
96
+ function identifier(value, fallback = "") {
97
+ const id = typeof value === "string" ? value.trim() : "";
98
+ return id || fallback;
99
+ }
100
+
101
+ function invalid(reason) {
102
+ return Object.freeze({ invalid: true, reason });
103
+ }
104
+
105
+ function isLoopbackUrl(url) {
106
+ try {
107
+ const parsed = new URL(url);
108
+ return parsed.hostname === "127.0.0.1"
109
+ || parsed.hostname === "localhost"
110
+ || parsed.hostname === "::1";
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Digest of the inbound principal. Tokens and secrets are not inputs.
118
+ *
119
+ * @param {object|null} identity
120
+ * @returns {string}
121
+ */
122
+ export function identityId(identity) {
123
+ if (!isRecord(identity)) return "";
124
+ const issuer = typeof identity.iss === "string"
125
+ ? identity.iss
126
+ : (typeof identity.issuer === "string" ? identity.issuer : "");
127
+ const subject = typeof identity.sub === "string" && identity.sub
128
+ ? identity.sub
129
+ : (typeof identity.subject === "string" ? identity.subject : "");
130
+ const clientId = typeof identity.clientId === "string" ? identity.clientId : "";
131
+ if (!subject && !clientId) return "";
132
+ return digestPayload({ issuer, subject, clientId });
133
+ }
134
+
135
+ /**
136
+ * @param {string|null|undefined} delegator
137
+ * @returns {string}
138
+ */
139
+ export function delegationId(delegator) {
140
+ return identifier(delegator, ABSENT.delegation);
141
+ }
142
+
143
+ /**
144
+ * @param {unknown} obligations
145
+ * @returns {string}
146
+ */
147
+ export function obligationId(obligations) {
148
+ if (!Array.isArray(obligations) || obligations.length === 0) return ABSENT.obligations;
149
+ return digestPayload(obligations);
150
+ }
151
+
152
+ /**
153
+ * @param {string|null|undefined} approval
154
+ * @returns {string}
155
+ */
156
+ export function approvalId(approval) {
157
+ return identifier(approval, ABSENT.approval);
158
+ }
159
+
160
+ /**
161
+ * Normalize `auth.evidenceAnchor`. Absent / comment-only is omitted.
162
+ * Anything else that is not `{ url, publicKey }` (or a test `{ submit,
163
+ * publicKey }`) is invalid and refuses startup.
164
+ *
165
+ * @param {object|null|undefined} raw
166
+ * @returns {object|null}
167
+ */
168
+ export function normalizeEvidenceAnchor(raw) {
169
+ if (raw === null || raw === undefined) return null;
170
+ if (!isRecord(raw)) return invalid("evidenceAnchor");
171
+ const entries = Object.entries(raw)
172
+ .filter(([key]) => cleanKey(key));
173
+ if (!entries.length) return null;
174
+ const bag = new Map(entries);
175
+ const publicKey = typeof bag.get("publicKey") === "string" ? bag.get("publicKey").trim() : "";
176
+ if (!publicKey) return invalid("evidenceAnchor.publicKey");
177
+ try {
178
+ loadPinnedPublicKey(publicKey);
179
+ } catch {
180
+ return invalid("evidenceAnchor.publicKey");
181
+ }
182
+ const submit = bag.get("submit");
183
+ if (typeof submit === "function") {
184
+ return Object.freeze({
185
+ publicKey,
186
+ submit,
187
+ timeoutMs: 2000,
188
+ });
189
+ }
190
+ const url = typeof bag.get("url") === "string" ? bag.get("url").trim() : "";
191
+ if (!url) return invalid("evidenceAnchor.url");
192
+ let parsed;
193
+ try {
194
+ parsed = new URL(url);
195
+ } catch {
196
+ return invalid("evidenceAnchor.url");
197
+ }
198
+ if (parsed.protocol === "http:") {
199
+ if (!isLoopbackUrl(url)) return invalid("evidenceAnchor.url");
200
+ } else if (parsed.protocol !== "https:") {
201
+ return invalid("evidenceAnchor.url");
202
+ }
203
+ const timeoutRaw = bag.get("timeoutMs");
204
+ const timeoutMs = timeoutRaw === undefined
205
+ ? 2000
206
+ : (Number.isInteger(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : null);
207
+ if (timeoutMs === null) return invalid("evidenceAnchor.timeoutMs");
208
+ return Object.freeze({ url, publicKey, timeoutMs });
209
+ }
210
+
211
+ /**
212
+ * @param {object} [raw]
213
+ * @returns {object}
214
+ */
215
+ export function createExecutionChain(raw = {}) {
216
+ const tenant = identifier(raw.tenant);
217
+ const requestId = identifier(raw.requestId);
218
+ const localExecutionId = identifier(raw.localExecutionId, requestId || ABSENT.localExecution);
219
+ return Object.freeze({
220
+ schema: "sentinel-execution-v1",
221
+ tenant,
222
+ identityId: identifier(raw.identityId),
223
+ delegationId: identifier(raw.delegationId, ABSENT.delegation),
224
+ decisionId: identifier(raw.decisionId),
225
+ obligationId: identifier(raw.obligationId, ABSENT.obligations),
226
+ approvalId: identifier(raw.approvalId, ABSENT.approval),
227
+ localExecutionId,
228
+ targetRevision: identifier(raw.targetRevision, ABSENT.targetRevision),
229
+ receiptId: identifier(raw.receiptId),
230
+ policyDigest: identifier(raw.policyDigest),
231
+ outcome: typeof raw.outcome === "string" ? raw.outcome : null,
232
+ requestId: requestId || (localExecutionId.startsWith("none:") ? "" : localExecutionId),
233
+ receiptDecisionId: identifier(raw.receiptDecisionId),
234
+ receiptTenant: identifier(raw.receiptTenant),
235
+ });
236
+ }
237
+
238
+ /**
239
+ * @param {object} chain
240
+ * @returns {{state: string, reason: string|null}}
241
+ */
242
+ export function reconcileExecution(chain) {
243
+ if (!isRecord(chain)) {
244
+ return { state: "incomplete", reason: "missing_identifier" };
245
+ }
246
+ const required = ["tenant", "identityId", "decisionId", "receiptId", "localExecutionId", "delegationId", "obligationId", "approvalId", "targetRevision"];
247
+ const values = new Map(Object.entries(chain));
248
+ if (required.some((key) => !identifier(values.get(key)))) {
249
+ return { state: "incomplete", reason: "missing_identifier" };
250
+ }
251
+ if (chain.policyDigest && !POLICY_DIGEST.test(chain.policyDigest)) {
252
+ return { state: "mismatched", reason: "policy_digest" };
253
+ }
254
+ if (chain.requestId && chain.localExecutionId && !chain.localExecutionId.startsWith("none:")
255
+ && chain.requestId !== chain.localExecutionId) {
256
+ return { state: "mismatched", reason: "execution_mismatch" };
257
+ }
258
+ if (chain.receiptDecisionId && chain.receiptDecisionId !== chain.decisionId) {
259
+ return { state: "mismatched", reason: "decision_mismatch" };
260
+ }
261
+ if (chain.receiptTenant && chain.receiptTenant !== chain.tenant) {
262
+ return { state: "mismatched", reason: "tenant_mismatch" };
263
+ }
264
+ return { state: "settled", reason: null };
265
+ }
266
+
267
+ /**
268
+ * Minimized digest: the eight ids, tenant, policy digest, and outcome.
269
+ * @param {object} chain
270
+ * @returns {string}
271
+ */
272
+ export function digestExecution(chain) {
273
+ return digestPayload({
274
+ identityId: chain.identityId,
275
+ delegationId: chain.delegationId,
276
+ decisionId: chain.decisionId,
277
+ obligationId: chain.obligationId,
278
+ approvalId: chain.approvalId,
279
+ localExecutionId: chain.localExecutionId,
280
+ targetRevision: chain.targetRevision,
281
+ receiptId: chain.receiptId,
282
+ tenant: chain.tenant,
283
+ policyDigest: chain.policyDigest || "",
284
+ outcome: chain.outcome || "",
285
+ });
286
+ }
287
+
288
+ function positiveInt(value) {
289
+ return Number.isInteger(value) && value > 0 ? value : null;
290
+ }
291
+
292
+ /**
293
+ * Bounded in-process evidence ledger. Tenant-partitioned. Restart clears it.
294
+ *
295
+ * @param {object} [options]
296
+ * @param {() => number} [options.now]
297
+ * @param {number} [options.maxRecords]
298
+ * @returns {object}
299
+ */
300
+ export function createEvidenceLedger({
301
+ now = () => Date.now(),
302
+ maxRecords = DEFAULT_MAX_RECORDS,
303
+ } = {}) {
304
+ const bound = positiveInt(maxRecords);
305
+ if (bound === null) {
306
+ throw new TypeError("createEvidenceLedger requires maxRecords to be a positive integer.");
307
+ }
308
+ const rows = [];
309
+ let dropped = 0;
310
+
311
+ return {
312
+ get size() {
313
+ return rows.length;
314
+ },
315
+
316
+ /**
317
+ * @param {object} chain
318
+ * @param {object|null} [inclusion]
319
+ * @param {string|null} [publicPin]
320
+ * @returns {object}
321
+ */
322
+ record(chain, inclusion = null, publicPin = null) {
323
+ const frozenChain = createExecutionChain(chain);
324
+ const reconciliation = reconcileExecution(frozenChain);
325
+ const digest = digestExecution(frozenChain);
326
+ let anchored = false;
327
+ let verifiedInclusion = null;
328
+ if (inclusion && publicPin) {
329
+ const checked = verifyInclusion(publicPin, inclusion);
330
+ anchored = checked.ok && inclusion.receiptDigest === digest;
331
+ verifiedInclusion = anchored ? inclusion : null;
332
+ }
333
+ const row = Object.freeze({
334
+ ...frozenChain,
335
+ digest,
336
+ reconciliation: Object.freeze(reconciliation),
337
+ inclusion: verifiedInclusion,
338
+ anchored,
339
+ at: new Date(now()).toISOString(),
340
+ });
341
+ rows.push(row);
342
+ if (rows.length > bound) {
343
+ rows.shift();
344
+ dropped += 1;
345
+ }
346
+ return row;
347
+ },
348
+
349
+ /** @returns {object[]} */
350
+ records() {
351
+ return rows.slice();
352
+ },
353
+
354
+ /**
355
+ * @param {{tenant?: string|null}} [params]
356
+ * @returns {object[]}
357
+ */
358
+ query({ tenant = null } = {}) {
359
+ const tenantId = typeof tenant === "string" ? tenant.trim() : "";
360
+ if (!tenantId) return [];
361
+ return rows.filter((row) => row.tenant === tenantId);
362
+ },
363
+
364
+ /** @returns {{size: number, dropped: number, maxRecords: number}} */
365
+ stats() {
366
+ return { size: rows.length, dropped, maxRecords: bound };
367
+ },
368
+ };
369
+ }
370
+
371
+ const READ_DENIED = Object.freeze({ ok: false, reason: "not_entitled" });
372
+
373
+ /**
374
+ * Tenant-scoped evidence read. Same grant rule as usage: tenant from
375
+ * `auth.tenantGrants`; a `tenant` argument is a confirming hint.
376
+ *
377
+ * @param {object} params
378
+ * @returns {{ok: true, tenant: string, records: object[]}|{ok: false, reason: "not_entitled"}}
379
+ */
380
+ export function readEvidence({
381
+ identity = null,
382
+ tenantGrants = null,
383
+ tenant = null,
384
+ ledger = null,
385
+ } = {}) {
386
+ if (!ledger || typeof ledger.query !== "function") return READ_DENIED;
387
+ if (!isRecord(identity) || typeof identity.clientId !== "string" || !identity.clientId) {
388
+ return READ_DENIED;
389
+ }
390
+ if (!isRecord(tenantGrants)) return READ_DENIED;
391
+ const granted = grantIds(new Map(Object.entries(tenantGrants)).get(identity.clientId));
392
+ if (!granted.length) return READ_DENIED;
393
+ const hint = typeof tenant === "string" && tenant.trim() ? tenant.trim() : null;
394
+ if (hint && !granted.includes(hint)) return READ_DENIED;
395
+ if (!hint && granted.length > 1) return READ_DENIED;
396
+ const resolved = hint ?? granted[0];
397
+ return { ok: true, tenant: resolved, records: ledger.query({ tenant: resolved }) };
398
+ }
399
+
400
+ function forbiddenKey(key) {
401
+ return FORBIDDEN_EXPORT_KEYS.has(String(key).toLowerCase());
402
+ }
403
+
404
+ /**
405
+ * Walk a value and fail if a forbidden key is present.
406
+ * @param {*} value
407
+ * @returns {boolean}
408
+ */
409
+ export function isDataMinimized(value) {
410
+ if (Array.isArray(value)) return value.every(isDataMinimized);
411
+ if (!isRecord(value)) return true;
412
+ return Object.entries(value).every(([key, child]) => !forbiddenKey(key) && isDataMinimized(child));
413
+ }
414
+
415
+ function minimizeInclusion(inclusion) {
416
+ if (!isRecord(inclusion)) return null;
417
+ return Object.freeze({
418
+ schema: inclusion.schema,
419
+ algorithm: inclusion.algorithm,
420
+ anchorId: inclusion.anchorId,
421
+ receiptDigest: inclusion.receiptDigest,
422
+ signedAt: inclusion.signedAt,
423
+ keyId: inclusion.keyId,
424
+ signature: inclusion.signature,
425
+ });
426
+ }
427
+
428
+ function minimizeExecution(row) {
429
+ return Object.freeze({
430
+ identityId: row.identityId,
431
+ delegationId: row.delegationId,
432
+ decisionId: row.decisionId,
433
+ obligationId: row.obligationId,
434
+ approvalId: row.approvalId,
435
+ localExecutionId: row.localExecutionId,
436
+ targetRevision: row.targetRevision,
437
+ receiptId: row.receiptId,
438
+ policyDigest: row.policyDigest || null,
439
+ outcome: row.outcome,
440
+ reconcileState: row.reconciliation?.state ?? "incomplete",
441
+ digest: row.digest,
442
+ anchorId: row.inclusion?.anchorId ?? null,
443
+ inclusion: minimizeInclusion(row.inclusion),
444
+ anchored: row.anchored === true,
445
+ });
446
+ }
447
+
448
+ function citeRow(row) {
449
+ return Object.freeze({
450
+ receiptId: row.receiptId,
451
+ anchorId: row.anchorId,
452
+ digest: row.digest,
453
+ });
454
+ }
455
+
456
+ function liveAnchored(row, liveDigest) {
457
+ return row.anchored
458
+ && row.reconcileState === "settled"
459
+ && row.policyDigest === liveDigest;
460
+ }
461
+
462
+ function findOnboardOutcomes(executions, liveDigest) {
463
+ const settled = executions.filter((row) => liveAnchored(row, liveDigest));
464
+ const allowed = settled.find((row) => (
465
+ row.outcome === "ok"
466
+ && (!row.approvalId || row.approvalId === ABSENT.approval)
467
+ )) ?? null;
468
+ const denied = settled.find((row) => row.outcome === "denied") ?? null;
469
+ const gated = settled.find((row) => (
470
+ row.outcome === "require_approval"
471
+ || (row.outcome === "ok" && row.approvalId && row.approvalId !== ABSENT.approval)
472
+ )) ?? null;
473
+ return { allowed, denied, gated, any: settled[0] ?? null };
474
+ }
475
+
476
+ function findOffboardOutcomes(executions, liveDigest) {
477
+ const settled = executions.filter((row) => liveAnchored(row, liveDigest));
478
+ const revoked = settled.find((row) => row.outcome === "revoked") ?? null;
479
+ const destroyed = settled.find((row) => row.outcome === "destroyed") ?? null;
480
+ return { revoked, destroyed, any: settled[0] ?? null };
481
+ }
482
+
483
+ /**
484
+ * Data-minimized assessor export bound to the live policy digest.
485
+ *
486
+ * @param {object} params
487
+ * @param {object|null} params.identity
488
+ * @param {object|null} params.tenantGrants
489
+ * @param {string|null} [params.tenant]
490
+ * @param {object|null} params.ledger
491
+ * @param {string|null} [params.policyDigest] Live bound digest (server-resolved).
492
+ * @param {boolean} [params.attested]
493
+ * @param {() => Date} [params.now]
494
+ * @returns {{ok: true, tenant: string, policyDigest: string|null, executions: object[], controls: object[]}|{ok: false, reason: string}}
495
+ */
496
+ export function exportAssessor({
497
+ identity = null,
498
+ tenantGrants = null,
499
+ tenant = null,
500
+ ledger = null,
501
+ policyDigest = null,
502
+ attested = false,
503
+ now = () => new Date(),
504
+ } = {}) {
505
+ const read = readEvidence({ identity, tenantGrants, tenant, ledger });
506
+ if (!read.ok) return read;
507
+ const liveDigest = typeof policyDigest === "string" && POLICY_DIGEST.test(policyDigest)
508
+ ? policyDigest.toLowerCase()
509
+ : null;
510
+ const executions = read.records.map(minimizeExecution);
511
+ const evidenced = liveDigest
512
+ ? executions.find((row) => (
513
+ row.anchored
514
+ && row.reconcileState === "settled"
515
+ && row.policyDigest === liveDigest
516
+ )) ?? null
517
+ : null;
518
+
519
+ const onboard = liveDigest ? findOnboardOutcomes(executions, liveDigest) : null;
520
+ const offboard = liveDigest ? findOffboardOutcomes(executions, liveDigest) : null;
521
+ const controls = ASSESSOR_CONTROL_CATALOG.map((control) => {
522
+ if (control.source === "offboard") {
523
+ const complete = Boolean(offboard?.revoked && offboard?.destroyed);
524
+ return Object.freeze({
525
+ id: control.id,
526
+ title: control.title,
527
+ policyDigest: liveDigest,
528
+ evidence: complete
529
+ ? Object.freeze({
530
+ revoked: citeRow(offboard.revoked),
531
+ destroyed: citeRow(offboard.destroyed),
532
+ })
533
+ : null,
534
+ state: complete ? "evidenced" : "residual",
535
+ reason: complete
536
+ ? null
537
+ : (!liveDigest
538
+ ? "no_live_policy_digest"
539
+ : (offboard?.any ? "incomplete_offboard_outcomes" : "no_anchored_execution")),
540
+ });
541
+ }
542
+ if (control.source === "onboard") {
543
+ const complete = Boolean(onboard?.allowed && onboard?.denied && onboard?.gated);
544
+ return Object.freeze({
545
+ id: control.id,
546
+ title: control.title,
547
+ policyDigest: liveDigest,
548
+ evidence: complete
549
+ ? Object.freeze({
550
+ allowed: citeRow(onboard.allowed),
551
+ denied: citeRow(onboard.denied),
552
+ approvalGated: citeRow(onboard.gated),
553
+ })
554
+ : null,
555
+ state: complete ? "evidenced" : "residual",
556
+ reason: complete
557
+ ? null
558
+ : (!liveDigest
559
+ ? "no_live_policy_digest"
560
+ : (onboard?.any ? "incomplete_onboard_outcomes" : "no_anchored_execution")),
561
+ });
562
+ }
563
+ const cites = Boolean(evidenced && control.source === "anchor");
564
+ return Object.freeze({
565
+ id: control.id,
566
+ title: control.title,
567
+ policyDigest: liveDigest,
568
+ evidence: cites ? citeRow(evidenced) : null,
569
+ state: cites ? "evidenced" : "residual",
570
+ reason: cites
571
+ ? null
572
+ : (!liveDigest
573
+ ? "no_live_policy_digest"
574
+ : (evidenced ? "not_in_this_export" : "no_anchored_execution")),
575
+ });
576
+ });
577
+
578
+ const pack = {
579
+ ok: true,
580
+ tenant: read.tenant,
581
+ generatedAt: now().toISOString(),
582
+ policyDigest: liveDigest,
583
+ attested: attested === true && Boolean(liveDigest),
584
+ executions,
585
+ controls,
586
+ residuals: Object.freeze([
587
+ Object.freeze({
588
+ id: "shared_host_notary",
589
+ status: "managed",
590
+ detail:
591
+ "The lab notary may share a host with the edge. Production placement "
592
+ + "must be a separately administered sink; this export does not choose one.",
593
+ }),
594
+ Object.freeze({
595
+ id: "not_a_hosted_admission",
596
+ status: "managed",
597
+ detail:
598
+ "Loopback evidence is not hosted design-partner admission. Laboratory "
599
+ + "onboard and offboard may be cited here; hosted admission remains a later cut.",
600
+ }),
601
+ ]),
602
+ };
603
+ if (!isDataMinimized(pack)) {
604
+ throw new Error("Assessor export carried a forbidden key; the pack was not emitted.");
605
+ }
606
+ return pack;
607
+ }
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { timingSafeEqual } from "crypto";
13
- import { readFileSync, statSync } from "fs";
13
+ import { readFileSync, statSync, writeFileSync } from "fs";
14
14
  import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
15
15
 
16
16
  /** Header names that must never become identity. */
@@ -248,11 +248,15 @@ export function authorizationServerDiscoveryUrls(issuer) {
248
248
  *
249
249
  * @param {object} options
250
250
  * @param {string} [options.filePath]
251
- * @returns {{isRevoked: (identity: {jti?: ?string, sub?: ?string}) => boolean}}
251
+ * @returns {{
252
+ * isRevoked: (identity: {jti?: ?string, sub?: ?string}) => boolean,
253
+ * revoke: (identity: {jti?: ?string}) => {ok: true}|{ok: false, reason: string},
254
+ * }}
252
255
  */
253
256
  export function createRevocationStore({
254
257
  filePath,
255
258
  readFile = readFileSync,
259
+ writeFile = writeFileSync,
256
260
  stat = statSync,
257
261
  } = {}) {
258
262
  let cache = { mtimeMs: Number.NaN, jti: new Set(), sub: new Set(), denyAll: false };
@@ -290,6 +294,56 @@ export function createRevocationStore({
290
294
  if (identity.sub && sub.has(identity.sub)) return true;
291
295
  return false;
292
296
  },
297
+
298
+ /**
299
+ * Persist one jti and apply it in-process. The next authenticate does
300
+ * not wait on mtime; a leftover file still denies after restart.
301
+ *
302
+ * @param {{jti?: ?string}} identity
303
+ * @returns {{ok: true}|{ok: false, reason: string}}
304
+ */
305
+ revoke(identity) {
306
+ const tokenId = typeof identity?.jti === "string" ? identity.jti.trim() : "";
307
+ if (!tokenId || !filePath) {
308
+ return { ok: false, reason: "unreadable" };
309
+ }
310
+ let exists = false;
311
+ try {
312
+ stat(filePath);
313
+ exists = true;
314
+ } catch (err) {
315
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") {
316
+ return { ok: false, reason: "unreadable" };
317
+ }
318
+ }
319
+ let raw;
320
+ if (exists) {
321
+ try {
322
+ raw = JSON.parse(readFile(filePath, "utf8"));
323
+ } catch {
324
+ return { ok: false, reason: "unreadable" };
325
+ }
326
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
327
+ return { ok: false, reason: "unreadable" };
328
+ }
329
+ } else {
330
+ raw = { jti: [], sub: [] };
331
+ }
332
+ const nextJti = new Set([...(raw.jti ?? []).map(String), tokenId]);
333
+ const nextSub = new Set((raw.sub ?? []).map(String));
334
+ try {
335
+ writeFile(filePath, JSON.stringify({ jti: [...nextJti], sub: [...nextSub] }));
336
+ } catch {
337
+ return { ok: false, reason: "unreadable" };
338
+ }
339
+ cache = {
340
+ mtimeMs: Number.NaN,
341
+ jti: nextJti,
342
+ sub: nextSub,
343
+ denyAll: false,
344
+ };
345
+ return { ok: true };
346
+ },
293
347
  };
294
348
  }
295
349
 
@@ -529,7 +583,12 @@ export function resolveInboundAuthConfig(cfg = {}, env = process.env) {
529
583
  /**
530
584
  * Build the live inbound authenticator for HTTPS.
531
585
  * @param {object} options
532
- * @returns {Promise<{authenticate: Function, protectedResource: object, resourceMetadataUrl: string}>}
586
+ * @returns {Promise<{
587
+ * authenticate: Function,
588
+ * protectedResource: object,
589
+ * resourceMetadataUrl: string,
590
+ * revoke?: (jti: string) => {ok: true}|{ok: false, reason: string},
591
+ * }>}
533
592
  */
534
593
  export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
535
594
  if (!inboundCfg.issuer || !inboundCfg.audience) {
@@ -587,5 +646,8 @@ export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
587
646
  scopesSupported: inboundCfg.requiredScopes,
588
647
  }),
589
648
  resourceMetadataUrl,
649
+ ...(revocationStore ? {
650
+ revoke: (jti) => revocationStore.revoke({ jti }),
651
+ } : {}),
590
652
  };
591
653
  }