@kokuin/controller 0.1.0 → 0.2.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/lib/fold.js CHANGED
@@ -1,27 +1,10 @@
1
1
  import { digestOf, isCanonicalizable } from './canonical.js';
2
- import { keyFromTarget, keyTarget, verifyEventSignedBy, verifyInception, verifyReset, verifyRevoke, verifyRotate } from './events.js';
3
- import { createStateResolver } from './state-resolver.js';
4
- /**
5
- * Whether a verifier's answer is one this fold can act on. Total, and strict about both arms: an
6
- * `authorised: true` with no usable key would reach `verifyEventSignedBy` and throw, an
7
- * `authorised: false` with no reason would produce a non-string `FoldResult.reason`. Neither is
8
- * reachable from typed code, both from a stale build.
9
- *
10
- * The discriminant is compared against the literals, so a `'true'` string is malformed rather than
11
- * authorising. The key bytes go through `ArrayBuffer.isView`, not `instanceof Uint8Array`, which is
12
- * per-realm and would reject a correct answer built in a worker, a `vm` context, or across an
13
- * Electron bridge — the one way this guard could turn away a good answer.
14
- */ function isCapabilityAuthorisation(value) {
15
- if (value == null || typeof value !== 'object') {
16
- return false;
17
- }
18
- const answer = value;
19
- if (answer.authorised === true) {
20
- const key = answer.audienceKey;
21
- return key != null && typeof key.alg === 'string' && ArrayBuffer.isView(key.publicKey);
22
- }
23
- return answer.authorised === false && typeof answer.reason === 'string';
24
- }
2
+ import { foldEpoch } from './epoch.js';
3
+ import { keyFromTarget, keyTarget, verifyInception, verifyReset, verifyRotate } from './events.js';
4
+ import { MAX_LOG_EVENTS } from './limits.js';
5
+ import { isControllerLog, LOG_TOO_LARGE, MALFORMED_LOG } from './log.js';
6
+ import { denialTargetsMember, ROTATE_NEEDS_SNAPSHOT, validateSnapshot } from './members.js';
7
+ export { ROTATE_NEEDS_SNAPSHOT, SNAPSHOT_DROPS_RETIRED_KEY, SNAPSHOT_MALFORMED, SNAPSHOT_MEMBER_DID_MISMATCH, SNAPSHOT_MEMBER_KEY_IS_ROOT } from './members.js';
25
8
  function fail(reason, index) {
26
9
  return {
27
10
  ok: false,
@@ -30,25 +13,6 @@ function fail(reason, index) {
30
13
  };
31
14
  }
32
15
  /** What a log entry that is not a {@link SignedEvent} at all fails with. */ const MALFORMED_EVENT = 'malformed event';
33
- // The fold's capability-revoke failure reasons. Exported because they already are a contract:
34
- // `@kokuin/capability` asserts on them by string literal across a package boundary. `FoldResult.reason`
35
- // stays a plain string, so these name values rather than widening the type.
36
- /** A verifier threw. The thrown message is appended after `: `, so match with `startsWith`. */ export const CAPABILITY_VERIFIER_FAILED = 'capability verifier failed';
37
- /** A verifier answered with something that is not a {@link CapabilityAuthorisation}. */ export const CAPABILITY_VERIFIER_MALFORMED_ANSWER = 'capability verifier returned a malformed answer';
38
- /**
39
- * The capability authorised the revoke and pinned an audience key, and somebody else signed the
40
- * event. Distinct from a rejected grant: the delegation is sound and the signature is not.
41
- */ export const REVOKE_NOT_SIGNED_BY_AUDIENCE = 'revoke is not signed by the capability audience';
42
- /**
43
- * The sync fold met a capability-authorised revoke, which it cannot verify by construction — the
44
- * management tier working as designed, answered by `foldLogAsync`. The capability follows after `: `,
45
- * so match with `startsWith`. Exported so speculative folders (`resolveBranches`) can tell it from a
46
- * genuinely broken log.
47
- */ export const CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD = 'capability-authorised revoke needs an async fold';
48
- /**
49
- * The async fold met a capability-authorised revoke with no `verifyCapability` configured. Same
50
- * shape: the log is fine and the call was not equipped for it.
51
- */ export const CAPABILITY_REVOKE_NEEDS_VERIFIER = 'capability-authorised revoke needs a verifier';
52
16
  /**
53
17
  * Whether a log entry has the envelope shape everything downstream reads without checking. The
54
18
  * fold's input is untrusted (`JSON.parse` of whatever was on the wire), so `signed.event`/`signed.sigs`
@@ -80,10 +44,10 @@ function fail(reason, index) {
80
44
  }
81
45
  /**
82
46
  * Validate one event against the state so far and produce the next state. Pure and total — every
83
- * rejection is a returned reason, never a throw. Criticality is decided here so both entry points
84
- * inherit it: an unknown critical event fails closed, an unknown `crit: false` event at the next
85
- * position is skipped (prior state carried forward unchanged), and anything else fails closed.
86
- */ function stepEvent(did, inception, signed, prior) {
47
+ * rejection is a returned reason, never a throw. Criticality is decided here: an unknown critical
48
+ * event fails closed, an unknown `crit: false` event at the next position is skipped (prior state
49
+ * carried forward unchanged), and anything else fails closed.
50
+ */ function stepEvent(did, inception, signed, prior, chain) {
87
51
  if (!isSignedEventShape(signed)) {
88
52
  return {
89
53
  status: 'fail',
@@ -140,13 +104,72 @@ function fail(reason, index) {
140
104
  };
141
105
  }
142
106
  }
143
- // The deny set this rotate leaves behind: its own snapshot when it carries one (validated by
144
- // `isPublishedRotate`), else the accumulated set carried forward.
145
- const denyAfter = rot.event.d == null ? prior.deny : new Set(rot.event.d);
146
- // A key this event *establishes* must not be one it denies. `d` and the carried-forward set are
147
- // both attacker-influenced, so without this a single rotate could publish a key and deny it in
148
- // one breath, and the `KeyState.deny` invariant would hold only by good manners. Failing the fold
149
- // is the only answer: applying it leaves a head whose `k` resolves to nothing.
107
+ // Every rotate is a settlement: the state after it is its snapshot. A non-reset rotate must carry
108
+ // both halves — there is no "carry the prior state forward", since the prior state may hold epoch
109
+ // effects a chain-only verifier cannot reproduce, and half a snapshot would read as "no members"
110
+ // or "no denials" when its author meant neither. A reset without `m` is blind — `verifyReset`
111
+ // admits one only with `d` exactly `[]` — and a blind reset's state is empty.
112
+ if (!isReset && (rot.event.m == null || rot.event.d == null)) {
113
+ return {
114
+ status: 'fail',
115
+ reason: ROTATE_NEEDS_SNAPSHOT
116
+ };
117
+ }
118
+ const snapshot = rot.event.m == null ? {
119
+ members: [],
120
+ deny: []
121
+ } : {
122
+ members: rot.event.m,
123
+ deny: rot.event.d ?? []
124
+ };
125
+ // The generation's root keys and commitments, this event's included: a reset opens a new
126
+ // generation and is judged against its own keys alone; a rotate against every chain state of its
127
+ // generation. Skipped states repeat their prior, which the sets absorb.
128
+ const generation = isReset ? [] : chain.filter((state)=>state.gen === prior.gen);
129
+ const generationKeys = new Set([
130
+ ...rot.event.k,
131
+ ...rot.event.ka
132
+ ]);
133
+ const generationCommitments = new Set(rot.event.n);
134
+ for (const state of generation){
135
+ for (const key of [
136
+ ...state.keys,
137
+ ...state.agreement
138
+ ])generationKeys.add(key);
139
+ for (const commitment of state.next)generationCommitments.add(commitment);
140
+ }
141
+ // Tombstones: the retirements the prior state checkpointed — its key-target denials naming a key
142
+ // this generation's chain established. They must survive every later snapshot in the generation;
143
+ // read off the chain alone, so a pruned and a full store agree. Only a reset clears them.
144
+ const tombstones = new Set();
145
+ if (!isReset) {
146
+ for (const entry of prior.deny){
147
+ const key = keyFromTarget(entry);
148
+ if (key != null && generationKeys.has(key)) tombstones.add(entry);
149
+ }
150
+ }
151
+ const invalid = validateSnapshot({
152
+ snapshot,
153
+ generationKeys: [
154
+ ...generationKeys
155
+ ],
156
+ generationCommitments: [
157
+ ...generationCommitments
158
+ ],
159
+ recovery: prior.recovery,
160
+ tombstones
161
+ });
162
+ if (invalid != null) {
163
+ return {
164
+ status: 'fail',
165
+ reason: invalid
166
+ };
167
+ }
168
+ const denyAfter = new Set(snapshot.deny);
169
+ // A key this event *establishes* must not be one it denies. `d` is attacker-influenced wire data,
170
+ // so without this a single rotate could publish a key and deny it in one breath, and the
171
+ // `KeyState.deny` invariant would hold only by good manners. Failing the fold is the only answer:
172
+ // applying it leaves a head whose `k` resolves to nothing.
150
173
  const establishedButDenied = [
151
174
  ...rot.event.k,
152
175
  ...rot.event.ka
@@ -157,15 +180,32 @@ function fail(reason, index) {
157
180
  reason: `rotate establishes a denied key: ${establishedButDenied}`
158
181
  };
159
182
  }
183
+ // Only the fields a member carries: a snapshot entry is wire data and may hold anything else,
184
+ // which has no meaning to the fold and must not ride into the state as if it had.
185
+ const members = new Map();
186
+ for (const { did: memberDID, key, add, lease } of snapshot.members){
187
+ const entry = {
188
+ did: memberDID,
189
+ key,
190
+ add,
191
+ lease
192
+ };
193
+ const denied = snapshot.deny.some((denial)=>denialTargetsMember(denial, entry));
194
+ members.set(add, {
195
+ ...entry,
196
+ removed: false,
197
+ effective: !denied
198
+ });
199
+ }
200
+ const digest = digestOf(rot.event);
160
201
  return {
161
202
  status: 'ok',
162
203
  state: {
163
204
  did,
164
205
  gen: rot.event.g,
165
206
  seq: rot.event.s,
166
- // New keys are one derivation index on from the last that established one — not at `s`, which
167
- // any intervening revoke advanced. A reset opens a fresh generation, so its index restarts at
168
- // 0 (also its `s`).
207
+ // New keys are one derivation index on from the last that established one. A reset opens a
208
+ // fresh generation, so its index restarts at 0 (also its `s`).
169
209
  keyGen: rot.event.g,
170
210
  keySeq: isReset ? 0 : prior.keySeq + 1,
171
211
  keys: rot.event.k,
@@ -174,7 +214,10 @@ function fail(reason, index) {
174
214
  // Carried, never replaced — including across a reset. See `KeyState.recovery`.
175
215
  recovery: prior.recovery,
176
216
  deny: denyAfter,
177
- digest: digestOf(rot.event)
217
+ members,
218
+ epoch: digest,
219
+ saturated: false,
220
+ digest
178
221
  }
179
222
  };
180
223
  }
@@ -184,81 +227,12 @@ function fail(reason, index) {
184
227
  reason: 'event does not chain to the previous digest'
185
228
  };
186
229
  }
187
- if (event.t === 'rev') {
188
- const rev = signed;
189
- if (rev.event.g !== prior.gen || rev.event.s !== prior.seq + 1) {
190
- return {
191
- status: 'fail',
192
- reason: 'sequence gap'
193
- };
194
- }
195
- // `x` goes into the deny set and, for a cap-authorised revoke, into the verifier as the resource
196
- // asked for — where a wildcard grant would happily authorise denying `undefined`.
197
- if (typeof rev.event.x !== 'string') {
198
- return {
199
- status: 'fail',
200
- reason: 'revoke names no target'
201
- };
202
- }
203
- // `cap` crosses a callback boundary into caller-supplied code, the one place a wire value is
204
- // handed to something that cannot have checked it. `x` above was checked and this was not.
205
- if (rev.event.cap !== undefined && typeof rev.event.cap !== 'string') {
206
- return {
207
- status: 'fail',
208
- reason: 'revoke capability is not a serialized token'
209
- };
210
- }
211
- // A key target may not name a key this position currently publishes. `resolve` is head-only, so
212
- // a key still in `k` is one the profile asserts it signs with *now*; the event that stops that is
213
- // `rotate` (whose pre-rotation commitment the leaked-key holder cannot forge), and denial is for
214
- // what the rotate leaves behind — the historic path, which survives a rotate. So: rotate-then-deny.
215
- // Allowing it would let one cap-authorised revoke (wildcard `res`) from a device that never held
216
- // the sub-seed disable the root tier, and leave a head whose own `k` the resolver must refuse.
217
- const deniedKey = keyFromTarget(rev.event.x);
218
- if (deniedKey != null && (prior.keys.includes(deniedKey) || prior.agreement.includes(deniedKey))) {
219
- return {
220
- status: 'fail',
221
- reason: `revoke names a key the profile publishes: ${rev.event.x}`
222
- };
223
- }
224
- const deny = new Set(prior.deny);
225
- deny.add(rev.event.x);
226
- // `keyGen`/`keySeq` ride along in the spread: a revoke establishes no key, so the active position
227
- // is still wherever the last icp/rot put it.
228
- const state = {
229
- ...prior,
230
- seq: rev.event.s,
231
- deny,
232
- digest: digestOf(rev.event)
233
- };
234
- if (rev.event.cap != null) {
235
- return {
236
- status: 'capability',
237
- cap: rev.event.cap,
238
- target: rev.event.x,
239
- state,
240
- signed: rev
241
- };
242
- }
243
- if (!verifyRevoke(rev, {
244
- digest: prior.digest,
245
- keys: prior.keys
246
- })) {
247
- return {
248
- status: 'fail',
249
- reason: 'invalid revoke'
250
- };
251
- }
252
- return {
253
- status: 'ok',
254
- state
255
- };
256
- }
257
- // Unknown type. Criticality lives in the envelope so this decision needs no understanding of `t`;
258
- // failing closed on a critical event stops a verifier that does not understand `rev` accepting a
259
- // revoked device. Only an explicit `crit: false` skips: `crit` is wire data, an absent member is
260
- // falsy, so a truthiness test would let an attacker claim the skip path by *omitting* the flag. The
261
- // two reasons separate "declared critical" from "declared nothing".
230
+ // Unknown type — a `rev`, which the chain no longer carries, included. Criticality lives in the
231
+ // envelope so this decision needs no understanding of `t`; failing closed on a critical event stops
232
+ // a verifier that does not understand a type accepting what that type would have refused. Only an
233
+ // explicit `crit: false` skips: `crit` is wire data, an absent member is falsy, so a truthiness test
234
+ // would let an attacker claim the skip path by *omitting* the flag. The two reasons separate
235
+ // "declared critical" from "declared nothing".
262
236
  if (event.crit !== false) {
263
237
  return {
264
238
  status: 'fail',
@@ -299,11 +273,11 @@ function fail(reason, index) {
299
273
  * later-version non-critical types near the front, which is the whole point of the skip path.
300
274
  */ export const MAX_SKIPPED_SLACK = 8;
301
275
  /** What a log padded past {@link MAX_SKIPPED_SLACK} fails with. */ export const TOO_MANY_UNKNOWN_EVENTS = 'too many unknown events';
276
+ /** A fold-loop index landed off the event or state array — a broken invariant, not a bad log. */ export const INTERNAL_FOLD_INVARIANT = 'internal fold invariant: missing event or prior state';
302
277
  /**
303
- * The running budget for skipped events, shared by both fold entry points so the two cannot drift.
304
- * `understood` counts validated events (the inception is the first), `skipped` the ones carried past.
305
- * Checked as each skipped event arrives, so a padded log stops folding at the point it exceeds the
306
- * budget rather than after.
278
+ * The running budget for skipped events. `understood` counts validated events (the inception is the
279
+ * first), `skipped` the ones carried past. Checked as each skipped event arrives, so a padded log
280
+ * stops folding at the point it exceeds the budget rather than after.
307
281
  */ function skipBudget() {
308
282
  let understood = 1;
309
283
  let skipped = 0;
@@ -318,13 +292,12 @@ function fail(reason, index) {
318
292
  };
319
293
  }
320
294
  /**
321
- * Validate the inception and seed the state array both fold entry points start from. Shared so the
322
- * sync/async split begins only where the two loops differ.
295
+ * Validate the inception and seed the state array the fold starts from.
323
296
  */ function initFold(did, events) {
324
297
  if (!Array.isArray(events)) {
325
298
  return {
326
299
  ok: false,
327
- result: fail('malformed log', 0)
300
+ result: fail(MALFORMED_LOG, 0)
328
301
  };
329
302
  }
330
303
  if (events.length === 0) {
@@ -373,37 +346,44 @@ function fail(reason, index) {
373
346
  next: first.event.n,
374
347
  recovery: first.event.r,
375
348
  deny: new Set(),
349
+ // The inception opens the first epoch with an empty management set.
350
+ members: new Map(),
351
+ epoch: digestOf(first.event),
352
+ saturated: false,
376
353
  digest: digestOf(first.event)
377
354
  }
378
355
  ]
379
356
  };
380
357
  }
381
- /**
382
- * Fold a controller's log into per-position key state. `states[i]` is the state *after* `events[i]`,
383
- * which a verifier evaluating at position `i` must use — that is what makes the deny set
384
- * position-dependent: clearing a DID later never retroactively validates its earlier actions.
385
- *
386
- * Synchronous, so it stays usable on an offline verifier's apply path. A capability-authorised revoke
387
- * needs async verification, so it fails closed here rather than trusting an unchecked capability — use
388
- * `foldLogAsync` when one may be present.
389
- */ export function foldLog(did, events) {
358
+ /** The chain alone: one state per event, or the first event that does not fold. */ function foldChain(did, events) {
390
359
  const init = initFold(did, events);
391
360
  if (!init.ok) {
392
- return init.result;
361
+ return init;
393
362
  }
394
363
  const { inception, states } = init;
395
364
  const budget = skipBudget();
396
365
  for(let i = 1; i < events.length; i++){
397
- const outcome = stepEvent(did, inception, events[i], states[i - 1]);
398
- if (outcome.status === 'fail') {
399
- return fail(outcome.reason, i);
366
+ const prior = states[i - 1];
367
+ if (prior === undefined) {
368
+ return {
369
+ ok: false,
370
+ result: fail(INTERNAL_FOLD_INVARIANT, i)
371
+ };
400
372
  }
401
- if (outcome.status === 'capability') {
402
- return fail(`${CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD}: ${outcome.cap}`, i);
373
+ // `events[i]` may be a hole or `undefined`; `stepEvent` reports that as `malformed event`.
374
+ const outcome = stepEvent(did, inception, events[i], prior, states);
375
+ if (outcome.status === 'fail') {
376
+ return {
377
+ ok: false,
378
+ result: fail(outcome.reason, i)
379
+ };
403
380
  }
404
381
  if (outcome.skipped) {
405
382
  if (!budget.skip()) {
406
- return fail(TOO_MANY_UNKNOWN_EVENTS, i);
383
+ return {
384
+ ok: false,
385
+ result: fail(TOO_MANY_UNKNOWN_EVENTS, i)
386
+ };
407
387
  }
408
388
  } else {
409
389
  budget.understood();
@@ -416,84 +396,79 @@ function fail(reason, index) {
416
396
  };
417
397
  }
418
398
  /**
419
- * Async counterpart of {@link foldLog}. Shares {@link stepEvent}, so the only difference is a
420
- * capability-authorised revoke: this entry point awaits `options.verifyCapability` instead of failing
421
- * closed.
422
- */ export async function foldLogAsync(did, events, options = {}) {
423
- const init = initFold(did, events);
424
- if (!init.ok) {
425
- return init.result;
399
+ * Fold a controller's log into per-position key state. `states[i]` is the state *after* `chain[i]`,
400
+ * which a verifier evaluating at position `i` must use — that is what makes the deny set
401
+ * position-dependent: clearing a DID later never retroactively validates its earlier actions.
402
+ *
403
+ * The epoch events then merge into the chain's last state to give `head` (see `foldEpoch`). Only request
404
+ * limits can fail that half: more than {@link MAX_LOG_EVENTS} entries is `LOG_TOO_LARGE`, at
405
+ * index -1 because it is no event's fault, and so is more than `MAX_FAILED_SIGNATURES` signatures
406
+ * failing to verify (`TOO_MANY_FAILED_SIGNATURES`). Short of those, nothing in `log.events` can fail
407
+ * the fold -- a bad entry is counted and dropped -- so a relay cannot make a log unfoldable by padding
408
+ * it, only refused by forging past the limit, which no honest relay does.
409
+ *
410
+ * Synchronous, so it stays usable on an offline verifier's apply path.
411
+ */ export function foldLog(did, log) {
412
+ if (!isControllerLog(log)) {
413
+ return fail(MALFORMED_LOG, 0);
426
414
  }
427
- const { inception, states } = init;
428
- const budget = skipBudget();
429
- for(let i = 1; i < events.length; i++){
430
- const outcome = stepEvent(did, inception, events[i], states[i - 1]);
431
- if (outcome.status === 'fail') {
432
- return fail(outcome.reason, i);
433
- }
434
- if (outcome.status === 'capability') {
435
- if (options.verifyCapability == null) {
436
- return fail(`${CAPABILITY_REVOKE_NEEDS_VERIFIER}: ${outcome.cap}`, i);
437
- }
438
- // The fold is total by contract; a verifier is caller-supplied code TypeScript cannot police
439
- // across a package boundary or a stale build. Both ways it can break the contract — throwing,
440
- // and answering with the wrong shape — come back as a `FoldResult` with a real reason, never an
441
- // escaping exception or an `undefined` reason. Our adapter never throws; a third party's need
442
- // not, and a throw is not evidence the capability authorises anything.
443
- let authorisation;
444
- try {
445
- authorisation = await options.verifyCapability({
446
- cap: outcome.cap,
447
- subject: did,
448
- target: outcome.target,
449
- // A copy: `states` keeps growing, and this resolver must keep answering for the position it
450
- // was built at even if the verifier holds on to it.
451
- subjectAtPosition: createStateResolver(did, [
452
- ...states
453
- ])
454
- });
455
- } catch (cause) {
456
- return fail(`${CAPABILITY_VERIFIER_FAILED}: ${cause instanceof Error ? cause.message : String(cause)}`, i);
457
- }
458
- if (!isCapabilityAuthorisation(authorisation)) {
459
- return fail(CAPABILITY_VERIFIER_MALFORMED_ANSWER, i);
460
- }
461
- if (!authorisation.authorised) {
462
- return fail(authorisation.reason, i);
463
- }
464
- // The capability authorises its audience, not its bearer. Checked here rather than left to the
465
- // verifier so a verifier that simply forgot cannot make the fold accept a revoke from anyone
466
- // who read the log — see `CapabilityAuthorisation`.
467
- if (!verifyEventSignedBy(outcome.signed, authorisation.audienceKey)) {
468
- return fail(REVOKE_NOT_SIGNED_BY_AUDIENCE, i);
469
- }
470
- }
471
- if (outcome.status === 'ok' && outcome.skipped) {
472
- if (!budget.skip()) {
473
- return fail(TOO_MANY_UNKNOWN_EVENTS, i);
474
- }
475
- } else {
476
- budget.understood();
477
- }
478
- states.push(outcome.state);
415
+ if (log.events.length > MAX_LOG_EVENTS) {
416
+ return fail(LOG_TOO_LARGE, -1);
417
+ }
418
+ const chain = foldChain(did, log.chain);
419
+ if (!chain.ok) {
420
+ return chain.result;
421
+ }
422
+ const { states } = chain;
423
+ const merged = foldEpoch({
424
+ did,
425
+ chainStates: states,
426
+ events: log.events
427
+ });
428
+ if (!merged.ok) {
429
+ return fail(merged.reason, -1);
479
430
  }
480
431
  return {
481
432
  ok: true,
482
- states
433
+ states,
434
+ head: merged.head,
435
+ applied: merged.applied,
436
+ pending: merged.pending,
437
+ rejected: merged.rejected
483
438
  };
484
439
  }
485
440
  export function keyStateAt(result, position) {
486
441
  return result.ok ? result.states[position] : undefined;
487
442
  }
488
443
  /**
489
- * The deny set of `state` with `drop` removed — what to pass as a rotate's `denySnapshot` to prune a
490
- * few entries rather than replace the set. A rotate's `d` replaces the accumulated set outright, so
491
- * pruning by hand means writing out everything that stays; miss one and the profile silently
492
- * un-revokes a device or un-retires a leaked key. This builds it from the fold's own answer instead.
493
- * Entries in `drop` the state does not carry are ignored.
494
- */ export function pruneDenySet(state, drop) {
495
- const dropped = new Set(drop);
496
- return [
497
- ...state.deny
498
- ].filter((entry)=>!dropped.has(entry));
444
+ * The state snapshot `state` holds, minus `drop` — what to pass as a rotate's `snapshot` to carry the
445
+ * state forward, pruning a few entries rather than replacing it. A rotate's snapshot replaces the
446
+ * state outright, so writing one by hand means writing out everything that stays; miss one and the
447
+ * profile silently un-revokes a device, un-retires a leaked key or dismisses a management member.
448
+ * This builds it from the fold's own answer instead: every member not removed (`drop.members` names
449
+ * members by `add` digest) and every deny entry (`drop.deny`). Entries in `drop` the state does not
450
+ * carry are ignored.
451
+ *
452
+ * Root's tooling owes its user the warnings the fold cannot give: dropping a holder DID denial
453
+ * re-authorises it, safe only once every authority that could have minted it a grant has ended; and
454
+ * a retired root key the chain shows but this deny set lacks is one to add, not to leave out.
455
+ */ export function snapshotOf(state, drop = {}) {
456
+ const droppedDeny = new Set(drop.deny ?? []);
457
+ const droppedMembers = new Set(drop.members ?? []);
458
+ const members = [];
459
+ for (const member of state.members.values()){
460
+ if (member.removed || droppedMembers.has(member.add)) continue;
461
+ members.push({
462
+ did: member.did,
463
+ key: member.key,
464
+ add: member.add,
465
+ lease: member.lease
466
+ });
467
+ }
468
+ return {
469
+ members,
470
+ deny: [
471
+ ...state.deny
472
+ ].filter((entry)=>!droppedDeny.has(entry))
473
+ };
499
474
  }