@emilia-protocol/gate 0.9.0 → 0.9.1

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,340 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — underwriter control attestation (AI-liability loss-run analogue).
4
+ *
5
+ * The artifact an AI-liability underwriter prices premium credit against — the
6
+ * MFA-for-cyber analogue: evidence that a deny-by-default authorization control
7
+ * was IN FORCE and OPERATING over the policy period, computed from the gate's
8
+ * tamper-evident evidence log. Pure function: same entries + same options in,
9
+ * identical JSON out (pin `now` for a byte-stable artifact).
10
+ *
11
+ * HONESTY BOUNDARY (carried inside the artifact): this attests CONTROL
12
+ * OPERATION only. It does not attest the business correctness of any authorized
13
+ * action, and it is not an insurance document until adopted by the carrier.
14
+ * Near-miss / remediation narrative belongs to the broker — the builder emits
15
+ * those fields as null and NEVER fabricates prose.
16
+ *
17
+ * Fail closed: a missing insured or an invalid period is an error, not a guess.
18
+ * Entries that cannot be verified as log records (unparseable time, missing
19
+ * hash, unknown kind, decision without an allow verdict) are EXCLUDED from every
20
+ * attested count and surfaced as integrity_warnings — the attestation never
21
+ * counts what it cannot account for. A zero-activity period is a valid (boring)
22
+ * attestation, not an error.
23
+ */
24
+
25
+ export const UNDERWRITER_ATTESTATION_VERSION = 'EP-GATE-UNDERWRITER-ATTESTATION-v1';
26
+
27
+ // The gate's assurance vocabulary. `software` is the single-approver baseline;
28
+ // the distribution always carries all three so an underwriter can see zeros.
29
+ const KNOWN_TIERS = ['class_a', 'quorum', 'software'];
30
+
31
+ function toMs(t) {
32
+ if (t == null) return null;
33
+ const ms = typeof t === 'number' ? t : Date.parse(t);
34
+ return Number.isFinite(ms) ? ms : null;
35
+ }
36
+
37
+ /** Deterministic object from a Map: keys sorted, never insertion-ordered. */
38
+ function sortedObject(map) {
39
+ const out = {};
40
+ for (const k of [...map.keys()].sort()) out[k] = map.get(k);
41
+ return out;
42
+ }
43
+
44
+ function countBy(items, keyFn) {
45
+ const m = new Map();
46
+ for (const it of items) {
47
+ const k = keyFn(it);
48
+ m.set(k, (m.get(k) || 0) + 1);
49
+ }
50
+ return m;
51
+ }
52
+
53
+ /** Tier histogram seeded with the known tiers so zeros are explicit. */
54
+ function tierDistribution(values) {
55
+ const m = new Map(KNOWN_TIERS.map((t) => [t, 0]));
56
+ for (const v of values) {
57
+ const k = typeof v === 'string' && v.length ? v : 'unspecified';
58
+ m.set(k, (m.get(k) || 0) + 1);
59
+ }
60
+ return sortedObject(m);
61
+ }
62
+
63
+ /** Action family = the namespace segment ('payment.release' -> 'payment'). */
64
+ function actionFamily(action) {
65
+ if (typeof action !== 'string' || action.length === 0) return 'unknown';
66
+ const dot = action.indexOf('.');
67
+ return dot > 0 ? action.slice(0, dot) : action;
68
+ }
69
+
70
+ /**
71
+ * Build the underwriter attestation over a slice of the evidence log.
72
+ * @param {Array<object>} entries evidence.all() (or a durable export of it)
73
+ * @param {object} o
74
+ * @param {string} o.insured named insured (required)
75
+ * @param {string} [o.policyRef] carrier policy/submission reference (null until bound)
76
+ * @param {string|number} o.periodStart inclusive period start (ISO or epoch ms)
77
+ * @param {string|number} o.periodEnd inclusive period end (ISO or epoch ms)
78
+ * @param {number|function} [o.now] clock for generated_at (pin for determinism)
79
+ * @returns {object} EP-GATE-UNDERWRITER-ATTESTATION-v1 document
80
+ */
81
+ export function buildUnderwriterAttestation(entries = [], {
82
+ insured, policyRef = null, periodStart, periodEnd, now = Date.now,
83
+ } = {}) {
84
+ if (!insured || typeof insured !== 'string') {
85
+ throw new Error('underwriter attestation: insured (named insured) is required');
86
+ }
87
+ const startMs = toMs(periodStart);
88
+ const endMs = toMs(periodEnd);
89
+ if (startMs == null || endMs == null) {
90
+ throw new Error('underwriter attestation: periodStart and periodEnd must be valid ISO strings or epoch ms');
91
+ }
92
+ if (startMs > endMs) {
93
+ throw new Error('underwriter attestation: periodStart must not be after periodEnd');
94
+ }
95
+ if (!Array.isArray(entries)) {
96
+ throw new Error('underwriter attestation: entries must be an array (evidence.all())');
97
+ }
98
+
99
+ // Scope + integrity pass. Out-of-window records are simply out of scope;
100
+ // records we cannot verify as log entries are warned AND excluded — never
101
+ // attested over. Exclusion is the conservative direction for premium credit.
102
+ const warnings = [];
103
+ const inScope = [];
104
+ entries.forEach((e, index) => {
105
+ const ref = { index, seq: e && typeof e === 'object' && Number.isInteger(e.seq) ? e.seq : null };
106
+ if (!e || typeof e !== 'object') { warnings.push({ ...ref, reason: 'not_an_object' }); return; }
107
+ const t = toMs(e.at);
108
+ if (t == null) { warnings.push({ ...ref, reason: 'unparseable_at' }); return; }
109
+ if (t < startMs || t > endMs) return;
110
+ if (typeof e.hash !== 'string' || e.hash.length === 0) { warnings.push({ ...ref, reason: 'missing_hash' }); return; }
111
+ if (e.kind !== 'decision' && e.kind !== 'execution') { warnings.push({ ...ref, reason: 'unknown_kind' }); return; }
112
+ if (e.kind === 'decision' && typeof e.allow !== 'boolean') { warnings.push({ ...ref, reason: 'decision_missing_allow' }); return; }
113
+ inScope.push({ entry: e, t });
114
+ });
115
+
116
+ const decisions = inScope.filter((x) => x.entry.kind === 'decision');
117
+ const executions = inScope.filter((x) => x.entry.kind === 'execution').map((x) => x.entry);
118
+
119
+ // 'not_guarded' pass-throughs ran WITHOUT the control — they are the
120
+ // uncontrolled surface, reported separately from protected-action volume.
121
+ const uncontrolled = decisions.filter((x) => x.entry.reason === 'not_guarded').map((x) => x.entry);
122
+ const guarded = decisions.filter((x) => x.entry.reason !== 'not_guarded');
123
+ const guardedAllows = guarded.filter((x) => x.entry.allow === true).map((x) => x.entry);
124
+ const denies = guarded.filter((x) => x.entry.allow === false).map((x) => x.entry);
125
+
126
+ const families = new Map();
127
+ for (const { entry: d } of guarded) {
128
+ const fam = actionFamily(d.action);
129
+ const f = families.get(fam) || { decisions: 0, allowed: 0, denied: 0 };
130
+ f.decisions += 1;
131
+ if (d.allow) f.allowed += 1; else f.denied += 1;
132
+ families.set(fam, f);
133
+ }
134
+
135
+ const reasons = sortedObject(countBy(denies, (d) => String(d.reason ?? 'unspecified')));
136
+ const replayBlocked = denies.filter((d) => d.reason === 'replay_refused').length;
137
+
138
+ // Hard actions = quorum required by the manifest, per the recorded decision.
139
+ const hard = guarded.filter((x) => x.entry.required_tier === 'quorum');
140
+ const hardAllowed = hard.filter((x) => x.entry.allow === true).length;
141
+
142
+ // Replay defense bypassed = the gate authorized with consumptionMode 'none'
143
+ // (one-time consumption explicitly skipped). An underwriter must see this.
144
+ const replayBypassed = guardedAllows.filter((a) => a.consumption_mode === 'none').length;
145
+
146
+ let firstAt = null; let lastAt = null;
147
+ for (const { entry: d, t } of decisions) {
148
+ if (firstAt === null || t < firstAt.t) firstAt = { t, at: d.at };
149
+ if (lastAt === null || t > lastAt.t) lastAt = { t, at: d.at };
150
+ }
151
+
152
+ const nowMs = typeof now === 'function' ? now() : now;
153
+
154
+ return {
155
+ '@version': UNDERWRITER_ATTESTATION_VERSION,
156
+ product: 'EMILIA Gate',
157
+ generated_at: new Date(nowMs).toISOString(),
158
+ insured,
159
+ policy_ref: policyRef ?? null,
160
+ period: { start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString() },
161
+ honesty: {
162
+ attests: 'Operation of a deny-by-default authorization control (EMILIA Gate) over the stated period, computed from the gate\'s tamper-evident evidence log.',
163
+ does_not_attest: [
164
+ 'The business correctness or wisdom of any authorized action.',
165
+ 'Identity proofing, authority enrollment, or issuer/approver key custody, which remain external trust roots.',
166
+ 'Any period, system, or enforcement point outside the supplied evidence log.',
167
+ ],
168
+ status: 'Not an insurance document. This attestation carries no coverage effect until adopted by the carrier.',
169
+ },
170
+ control_in_force: {
171
+ control: 'EMILIA Gate — Trusted Action Firewall',
172
+ mode: 'deny_by_default',
173
+ statement: 'Guarded actions execute only with a valid, in-scope, sufficiently-assured, fresh, one-time authorization receipt; absent or insufficient proof is refused, and every decision is appended to a hash-chained evidence log.',
174
+ guarded_decisions: guarded.length,
175
+ first_decision_at: firstAt?.at ?? null,
176
+ last_decision_at: lastAt?.at ?? null,
177
+ },
178
+ volume: {
179
+ guarded_decisions: guarded.length,
180
+ allowed: guardedAllows.length,
181
+ denied: denies.length,
182
+ by_action_family: sortedObject(families),
183
+ },
184
+ denials: {
185
+ total: denies.length,
186
+ // 0/0 is 'no guarded activity', not a 0% denial rate — represent as null.
187
+ rate: guarded.length === 0 ? null : denies.length / guarded.length,
188
+ reasons,
189
+ },
190
+ replay: { attempts_blocked: replayBlocked },
191
+ assurance: {
192
+ required_tier_distribution: tierDistribution(guarded.map((x) => x.entry.required_tier)),
193
+ credited_tier_distribution_on_allow: tierDistribution(guardedAllows.map((a) => a.have_tier)),
194
+ },
195
+ quorum_usage: {
196
+ hard_action_decisions: hard.length,
197
+ allowed: hardAllowed,
198
+ denied: hard.length - hardAllowed,
199
+ },
200
+ exceptions: {
201
+ uncontrolled_passthroughs: uncontrolled.length,
202
+ uncontrolled_actions: [...new Set(uncontrolled.map((d) => (typeof d.action === 'string' && d.action) || 'unknown'))].sort(),
203
+ replay_defense_bypassed: replayBypassed,
204
+ },
205
+ executions: {
206
+ recorded: executions.length,
207
+ executed: executions.filter((x) => x.outcome === 'executed').length,
208
+ failed: executions.filter((x) => x.outcome === 'failed').length,
209
+ },
210
+ // Broker fields. NEVER machine-generated — the builder only ever emits null.
211
+ narrative: { near_misses: null, remediation: null, completed_by: 'broker' },
212
+ evidence: {
213
+ log_entries_supplied: entries.length,
214
+ in_scope: inScope.length,
215
+ first_hash: inScope[0]?.entry.hash ?? null,
216
+ last_hash: inScope[inScope.length - 1]?.entry.hash ?? null,
217
+ integrity_warnings: warnings,
218
+ },
219
+ };
220
+ }
221
+
222
+ /* ------------------------------- markdown ------------------------------- */
223
+
224
+ /** Table cells must not break the table; the source strings are log-derived. */
225
+ function cell(v) {
226
+ return String(v ?? '—').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
227
+ }
228
+
229
+ function pct(rate) {
230
+ return rate == null ? 'n/a' : `${(rate * 100).toFixed(2)}%`;
231
+ }
232
+
233
+ function countTable(header, obj) {
234
+ const keys = Object.keys(obj);
235
+ if (keys.length === 0) return ['_None in period._'];
236
+ return [
237
+ `| ${header} | Count |`,
238
+ '|---|---:|',
239
+ ...keys.map((k) => `| ${cell(k)} | ${obj[k]} |`),
240
+ ];
241
+ }
242
+
243
+ const BROKER_PLACEHOLDER = '_(left to the broker — not machine-generated)_';
244
+
245
+ /**
246
+ * Render the attestation for a submission packet. Refuses any document that is
247
+ * not an EP-GATE-UNDERWRITER-ATTESTATION-v1 — never renders what it cannot
248
+ * vouch the shape of. Narrative fields render exactly as present in the pack
249
+ * (the broker fills them into the JSON); null renders as a placeholder.
250
+ */
251
+ export function renderMarkdown(pack) {
252
+ if (!pack || typeof pack !== 'object' || pack['@version'] !== UNDERWRITER_ATTESTATION_VERSION) {
253
+ throw new Error(`renderMarkdown requires an ${UNDERWRITER_ATTESTATION_VERSION} document`);
254
+ }
255
+ const fams = pack.volume.by_action_family;
256
+ const famKeys = Object.keys(fams);
257
+ const req = pack.assurance.required_tier_distribution;
258
+ const cred = pack.assurance.credited_tier_distribution_on_allow;
259
+ const tierKeys = [...new Set([...Object.keys(req), ...Object.keys(cred)])].sort();
260
+ const warnings = pack.evidence.integrity_warnings;
261
+
262
+ const lines = [
263
+ '# Underwriter Control Attestation — EMILIA Gate',
264
+ '',
265
+ `\`${UNDERWRITER_ATTESTATION_VERSION}\` · generated ${pack.generated_at}`,
266
+ '',
267
+ `**Insured:** ${cell(pack.insured)} · **Policy:** ${cell(pack.policy_ref)} · **Period:** ${pack.period.start} → ${pack.period.end}`,
268
+ '',
269
+ `> **Attests:** ${pack.honesty.attests}`,
270
+ `> **Does not attest:** ${pack.honesty.does_not_attest.join(' ')}`,
271
+ `> **Status:** ${pack.honesty.status}`,
272
+ '',
273
+ '## Control in force',
274
+ '',
275
+ `- Control: ${pack.control_in_force.control} (${pack.control_in_force.mode})`,
276
+ `- ${pack.control_in_force.statement}`,
277
+ `- Guarded decisions in period: ${pack.control_in_force.guarded_decisions}`
278
+ + (pack.control_in_force.first_decision_at
279
+ ? ` (first ${pack.control_in_force.first_decision_at}, last ${pack.control_in_force.last_decision_at})`
280
+ : ''),
281
+ '',
282
+ '## Protected-action volume',
283
+ '',
284
+ ...(famKeys.length === 0
285
+ ? ['_No guarded decisions in the period._']
286
+ : [
287
+ '| Action family | Decisions | Allowed | Denied |',
288
+ '|---|---:|---:|---:|',
289
+ ...famKeys.map((k) => `| ${cell(k)} | ${fams[k].decisions} | ${fams[k].allowed} | ${fams[k].denied} |`),
290
+ ]),
291
+ '',
292
+ '## Denials',
293
+ '',
294
+ `- Denials: ${pack.denials.total} of ${pack.volume.guarded_decisions} guarded decisions (${pct(pack.denials.rate)})`,
295
+ '',
296
+ ...countTable('Denial reason', pack.denials.reasons),
297
+ '',
298
+ '## Replay defense',
299
+ '',
300
+ `- Replay attempts blocked: ${pack.replay.attempts_blocked}`,
301
+ '',
302
+ '## Assurance tiers',
303
+ '',
304
+ '| Tier | Required (guarded) | Credited (allowed) |',
305
+ '|---|---:|---:|',
306
+ ...tierKeys.map((t) => `| ${cell(t)} | ${req[t] ?? 0} | ${cred[t] ?? 0} |`),
307
+ '',
308
+ '## Quorum usage on hard actions',
309
+ '',
310
+ `- Decisions requiring quorum: ${pack.quorum_usage.hard_action_decisions} (allowed ${pack.quorum_usage.allowed}, denied ${pack.quorum_usage.denied})`,
311
+ '',
312
+ '## Exceptions / uncontrolled actions',
313
+ '',
314
+ `- Uncontrolled pass-throughs (not guarded by manifest): ${pack.exceptions.uncontrolled_passthroughs}`,
315
+ `- Uncontrolled actions: ${pack.exceptions.uncontrolled_actions.length ? pack.exceptions.uncontrolled_actions.map(cell).join(', ') : '—'}`,
316
+ `- Replay defense bypassed (consumption mode "none"): ${pack.exceptions.replay_defense_bypassed}`,
317
+ '',
318
+ '## Executions',
319
+ '',
320
+ `- Recorded: ${pack.executions.recorded} (executed ${pack.executions.executed}, failed ${pack.executions.failed})`,
321
+ '',
322
+ '## Near-miss narrative (broker)',
323
+ '',
324
+ `- Near misses: ${pack.narrative.near_misses == null ? BROKER_PLACEHOLDER : cell(pack.narrative.near_misses)}`,
325
+ `- Remediation: ${pack.narrative.remediation == null ? BROKER_PLACEHOLDER : cell(pack.narrative.remediation)}`,
326
+ '',
327
+ '## Evidence basis',
328
+ '',
329
+ `- Log entries supplied: ${pack.evidence.log_entries_supplied} · in scope: ${pack.evidence.in_scope}`,
330
+ `- First hash: ${cell(pack.evidence.first_hash)} · Last hash: ${cell(pack.evidence.last_hash)}`,
331
+ `- Integrity warnings: ${warnings.length}`,
332
+ ...(warnings.length
333
+ ? warnings.map((w) => ` - index ${w.index}${w.seq != null ? ` (seq ${w.seq})` : ''}: ${cell(w.reason)}`)
334
+ : []),
335
+ '',
336
+ ];
337
+ return lines.join('\n');
338
+ }
339
+
340
+ export default { UNDERWRITER_ATTESTATION_VERSION, buildUnderwriterAttestation, renderMarkdown };
package/roster.js ADDED
@@ -0,0 +1,265 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — signer-roster sync from an enterprise IdP (EP-GATE-ROSTER-v1).
4
+ *
5
+ * The boring key story: WHO may approve is an HR fact, not a crypto fact. This
6
+ * module turns a SCIM-like IdP export into a versioned signer roster and
7
+ * reconciles a key registry (key-registry.js) against it, so a deprovisioned
8
+ * employee STOPS being an acceptable approver on the next sync. Fail closed:
9
+ * - only `active === true` users' keys are ever pinned; anything else
10
+ * (false, missing, truthy-but-not-boolean) never pins;
11
+ * - a user absent from the import — offboarded, or silently dropped by a
12
+ * broken IdP export — has every previously pinned key revoked;
13
+ * - a kid claimed by two principals (or carrying two different key
14
+ * materials) is CONTESTED: it pins nothing and is revoked if present;
15
+ * - an import that would leave ZERO active signers requires an explicit
16
+ * `allowEmpty` acknowledgment, so an empty/broken IdP response cannot
17
+ * silently mass-revoke every approver.
18
+ *
19
+ * importRoster/diffRoster are pure (inputs in, artifact out; `importedAt` is
20
+ * the caller-supplied clock). applyRosterToRegistry mutates the given registry
21
+ * through its real API: key-registry CAN express revocation (`revoke(kid, at)`,
22
+ * hard and fail-closed), so reconciliation performs it directly and the
23
+ * returned `revoked` list is the exact set of revocations performed.
24
+ */
25
+
26
+ export const ROSTER_VERSION = 'EP-GATE-ROSTER-v1';
27
+
28
+ /**
29
+ * Import a SCIM-like IdP user export into a signer roster.
30
+ * @param {Array<{id:string, userName:string, active:boolean, emails?:any, keys?:Array<{kid:string, publicKey:string}>}>} idpUsers
31
+ * @param {object} o
32
+ * @param {string} o.source IdP provenance, e.g. 'scim:okta:acme' (required)
33
+ * @param {string|number} [o.importedAt] import time (ISO or ms); the caller's clock
34
+ * @param {boolean} [o.allowEmpty=false] acknowledge an import with zero active signers
35
+ * @returns {{version:string, source:string, imported_at:string, signers:Array<{principal:string, kid:string, publicKey:string, active:boolean}>, integrity_warnings:object[]}}
36
+ */
37
+ export function importRoster(idpUsers, { source, importedAt, allowEmpty = false } = {}) {
38
+ if (!Array.isArray(idpUsers)) throw new Error('roster import: idpUsers must be an array');
39
+ if (!source || typeof source !== 'string') {
40
+ throw new Error('roster import: source (IdP provenance, e.g. "scim:okta:acme") is required');
41
+ }
42
+ const atMs = importedAt == null
43
+ ? Date.now()
44
+ : (typeof importedAt === 'number' ? importedAt : Date.parse(importedAt));
45
+ if (!Number.isFinite(atMs)) throw new Error('roster import: importedAt is not a valid time');
46
+
47
+ const warnings = [];
48
+ const candidates = [];
49
+ const usersByPrincipal = new Map();
50
+
51
+ for (const u of idpUsers) {
52
+ // A user without a stable id AND a userName cannot be diffed or revoked
53
+ // reliably later — excluded, never an approver.
54
+ if (!u || typeof u !== 'object'
55
+ || typeof u.id !== 'string' || !u.id
56
+ || typeof u.userName !== 'string' || !u.userName) {
57
+ warnings.push({ code: 'malformed_user', id: u?.id ?? null, userName: u?.userName ?? null });
58
+ continue;
59
+ }
60
+ const principal = u.userName;
61
+ usersByPrincipal.set(principal, (usersByPrincipal.get(principal) || 0) + 1);
62
+ const active = u.active === true; // strictly boolean true; anything else never pins
63
+ for (const k of Array.isArray(u.keys) ? u.keys : []) {
64
+ if (!k || typeof k.kid !== 'string' || !k.kid
65
+ || typeof k.publicKey !== 'string' || !k.publicKey) {
66
+ warnings.push({ code: 'malformed_key', principal, kid: k?.kid ?? null });
67
+ continue;
68
+ }
69
+ candidates.push({ principal, kid: k.kid, publicKey: k.publicKey, active });
70
+ }
71
+ }
72
+
73
+ // Two IdP users claiming one principal: identity is ambiguous — neither pins.
74
+ const contestedPrincipals = new Set();
75
+ for (const [principal, count] of usersByPrincipal) {
76
+ if (count > 1) {
77
+ contestedPrincipals.add(principal);
78
+ warnings.push({ code: 'duplicate_principal', principal, users: count });
79
+ }
80
+ }
81
+ let entries = candidates.filter((c) => !contestedPrincipals.has(c.principal));
82
+
83
+ // Exact-duplicate rows collapse silently (same principal + kid + key).
84
+ const seen = new Set();
85
+ entries = entries.filter((c) => {
86
+ const id = `${c.principal}\n${c.kid}\n${c.publicKey}`;
87
+ if (seen.has(id)) return false;
88
+ seen.add(id);
89
+ return true;
90
+ });
91
+
92
+ // One kid claimed by two principals, or one kid carrying two key materials,
93
+ // is an integrity failure: the contested kid pins NOTHING for anyone.
94
+ const byKid = new Map();
95
+ for (const c of entries) {
96
+ const g = byKid.get(c.kid) || { principals: new Set(), keys: new Set() };
97
+ g.principals.add(c.principal);
98
+ g.keys.add(c.publicKey);
99
+ byKid.set(c.kid, g);
100
+ }
101
+ const contestedKids = new Set();
102
+ for (const [kid, g] of byKid) {
103
+ if (g.principals.size > 1 || g.keys.size > 1) {
104
+ contestedKids.add(kid);
105
+ warnings.push({ code: 'duplicate_kid', kid, principals: [...g.principals].sort() });
106
+ }
107
+ }
108
+ entries = entries.filter((c) => !contestedKids.has(c.kid));
109
+
110
+ // Mass-deprovision guard: a roster with zero active signers would, on apply,
111
+ // revoke EVERY approver. That is indistinguishable from a broken IdP export,
112
+ // so it requires the operator's explicit acknowledgment.
113
+ if (!allowEmpty && !entries.some((e) => e.active)) {
114
+ throw new Error('roster import: zero active signers (mass-deprovision guard) — pass allowEmpty:true to acknowledge revoking every approver');
115
+ }
116
+
117
+ entries.sort((a, b) => (a.principal < b.principal ? -1 : a.principal > b.principal ? 1
118
+ : a.kid < b.kid ? -1 : a.kid > b.kid ? 1 : 0));
119
+
120
+ return {
121
+ version: ROSTER_VERSION,
122
+ source,
123
+ imported_at: new Date(atMs).toISOString(),
124
+ signers: entries.map(({ principal, kid, publicKey, active }) => ({ principal, kid, publicKey, active })),
125
+ integrity_warnings: warnings,
126
+ };
127
+ }
128
+
129
+ /** A roster must be well-formed before it can drive a diff or a registry mutation. */
130
+ function assertRoster(r, name) {
131
+ if (!r || r.version !== ROSTER_VERSION || !Array.isArray(r.signers)) {
132
+ throw new Error(`roster: ${name} is not an ${ROSTER_VERSION} roster`);
133
+ }
134
+ for (const s of r.signers) {
135
+ if (!s || typeof s.principal !== 'string' || !s.principal
136
+ || typeof s.kid !== 'string' || !s.kid
137
+ || typeof s.publicKey !== 'string' || !s.publicKey
138
+ || typeof s.active !== 'boolean') {
139
+ throw new Error(`roster: ${name} contains a malformed signer entry`);
140
+ }
141
+ }
142
+ }
143
+
144
+ function groupByPrincipal(roster) {
145
+ const m = new Map();
146
+ for (const s of roster.signers) {
147
+ const g = m.get(s.principal) || { kids: new Set(), active: false };
148
+ g.kids.add(s.kid);
149
+ if (s.active === true) g.active = true;
150
+ m.set(s.principal, g);
151
+ }
152
+ return m;
153
+ }
154
+
155
+ /**
156
+ * Diff two rosters at the principal level.
157
+ * `removed`/`deactivated` carry the PREVIOUS roster's kids — the revocation
158
+ * candidates; `added` carries the next roster's kids.
159
+ * @returns {{added:Array<{principal:string,kids:string[]}>, removed:Array<{principal:string,kids:string[]}>, deactivated:Array<{principal:string,kids:string[]}>}}
160
+ */
161
+ export function diffRoster(previous, next) {
162
+ assertRoster(previous, 'previous');
163
+ assertRoster(next, 'next');
164
+ const prev = groupByPrincipal(previous);
165
+ const nxt = groupByPrincipal(next);
166
+ const added = [];
167
+ const removed = [];
168
+ const deactivated = [];
169
+ for (const [principal, g] of nxt) {
170
+ if (!prev.has(principal)) added.push({ principal, kids: [...g.kids].sort() });
171
+ }
172
+ for (const [principal, g] of prev) {
173
+ if (!nxt.has(principal)) { removed.push({ principal, kids: [...g.kids].sort() }); continue; }
174
+ if (g.active && !nxt.get(principal).active) deactivated.push({ principal, kids: [...g.kids].sort() });
175
+ }
176
+ const byPrincipal = (a, b) => (a.principal < b.principal ? -1 : a.principal > b.principal ? 1 : 0);
177
+ added.sort(byPrincipal); removed.sort(byPrincipal); deactivated.sort(byPrincipal);
178
+ return { added, removed, deactivated };
179
+ }
180
+
181
+ /**
182
+ * Reconcile a key registry (createKeyRegistry) against a roster:
183
+ * - PIN each ACTIVE, uncontested signer's key not already present;
184
+ * - REVOKE every registry kid not owned by an active roster signer — absent
185
+ * or inactive means deprovisioned. The registry passed here must therefore
186
+ * be DEDICATED to roster-managed approver keys.
187
+ * key-registry's API DOES express revocation (revoke(kid, at) — hard,
188
+ * fail-closed), so revocations are performed directly; `revoked` is the exact
189
+ * set performed, returned for the caller's evidence trail.
190
+ * A kid the registry has EVER revoked is never re-pinned (a rehire gets a new
191
+ * key; revoked key material stays dead).
192
+ * @param {object} roster an EP-GATE-ROSTER-v1 roster
193
+ * @param {object} registry createKeyRegistry() instance (add/revoke/status)
194
+ * @param {object} [o]
195
+ * @param {string|number} [o.revokedAt=roster.imported_at] revocation timestamp
196
+ * @returns {{pinned:Array<{principal:string,kid:string}>, already_pinned:string[], revoked:Array<{kid:string,revoked_at:string|number,reason:string}>, refused:Array<{principal:string,kid:string,reason:string}>}}
197
+ */
198
+ export function applyRosterToRegistry(roster, registry, { revokedAt } = {}) {
199
+ assertRoster(roster, 'roster');
200
+ // Fail closed on the registry too: never coerce a flat key array here —
201
+ // asKeyRegistry would build a DETACHED registry and every revocation this
202
+ // function performs would be silently lost.
203
+ if (!registry || typeof registry.add !== 'function'
204
+ || typeof registry.revoke !== 'function' || typeof registry.status !== 'function') {
205
+ throw new Error('roster apply: registry must expose add/revoke/status (createKeyRegistry)');
206
+ }
207
+ const at = revokedAt ?? roster.imported_at;
208
+
209
+ // Recompute kid contests here as well — a hand-built roster must not bypass
210
+ // the import-time duplicate-kid integrity check.
211
+ const byKid = new Map();
212
+ for (const s of roster.signers) {
213
+ const g = byKid.get(s.kid) || { principals: new Set(), keys: new Set() };
214
+ g.principals.add(s.principal);
215
+ g.keys.add(s.publicKey);
216
+ byKid.set(s.kid, g);
217
+ }
218
+ const contested = new Set();
219
+ for (const [kid, g] of byKid) {
220
+ if (g.principals.size > 1 || g.keys.size > 1) contested.add(kid);
221
+ }
222
+
223
+ const activeByKid = new Map();
224
+ for (const s of roster.signers) {
225
+ if (s.active === true && !contested.has(s.kid)) activeByKid.set(s.kid, s);
226
+ }
227
+
228
+ const status = registry.status();
229
+ const everRevoked = new Set(status.filter((e) => e.revoked).map((e) => e.kid));
230
+ const present = new Set(status.map((e) => e.kid));
231
+
232
+ const pinned = [];
233
+ const alreadyPinned = [];
234
+ const revoked = [];
235
+ const refused = [];
236
+
237
+ for (const s of roster.signers) {
238
+ if (s.active === true && contested.has(s.kid)) {
239
+ refused.push({ principal: s.principal, kid: s.kid, reason: 'contested_kid' });
240
+ }
241
+ }
242
+
243
+ for (const [kid, s] of activeByKid) {
244
+ if (everRevoked.has(kid)) {
245
+ refused.push({ principal: s.principal, kid, reason: 'kid_previously_revoked' });
246
+ continue;
247
+ }
248
+ if (present.has(kid)) { alreadyPinned.push(kid); continue; }
249
+ registry.add({ kid, key: s.publicKey });
250
+ pinned.push({ principal: s.principal, kid });
251
+ }
252
+
253
+ // Absent-or-inactive (and contested) kids stop being acceptable NOW.
254
+ const done = new Set();
255
+ for (const e of status) {
256
+ if (e.revoked || done.has(e.kid) || activeByKid.has(e.kid)) continue;
257
+ registry.revoke(e.kid, at);
258
+ done.add(e.kid);
259
+ revoked.push({ kid: e.kid, revoked_at: at, reason: contested.has(e.kid) ? 'contested_kid' : 'absent_or_inactive' });
260
+ }
261
+
262
+ return { pinned, already_pinned: alreadyPinned, revoked, refused };
263
+ }
264
+
265
+ export default { ROSTER_VERSION, importRoster, diffRoster, applyRosterToRegistry };