@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/resolver.js CHANGED
@@ -1,13 +1,23 @@
1
1
  import { DID_PREFIX } from './events.js';
2
- import { authoritativeStates } from './history.js';
3
- import { allStatesAsync } from './state.js';
4
- import { agreementKeysFrom, DID_METHOD, signingKeyFrom } from './state-resolver.js';
2
+ import { authoritativeLogWithin } from './history.js';
3
+ import { isControllerLog } from './log.js';
4
+ import { resolverOver } from './state-resolver.js';
5
5
  const CONTEXT = 'Controller resolver';
6
6
  /**
7
7
  * A `did:kokuin:` resolver for `@kokuin/token`. Injected rather than imported by token: this package
8
8
  * depends on token for signing, so the reverse import would cycle. Token resolves `iss` through the
9
9
  * interface without knowing the fold exists.
10
+ *
11
+ * Every member answers from the head (the chain's last state merged with the current epoch) except
12
+ * `resolveHistoric`, which scans the chain's states for a rotated-away key. While the epoch is
13
+ * saturated, every member refuses (see `resolverOver`).
10
14
  */ export function createControllerResolver(options) {
15
+ return createControllerResolverWithin(options);
16
+ }
17
+ /**
18
+ * {@link createControllerResolver} under lowered epoch limits. Test-only: saturation is then
19
+ * reachable without thousands of signatures. Not exported from the package.
20
+ */ export function createControllerResolverWithin(options, limits) {
11
21
  // The fold currently under way for each DID, so overlapping resolutions of one DID share it (two
12
22
  // tokens from one issuer verified in parallel is ordinary — token caches only `did:peer:4`).
13
23
  //
@@ -18,43 +28,45 @@ const CONTEXT = 'Controller resolver';
18
28
  //
19
29
  // Not a cache: removed as soon as the fold settles, so a later resolution re-folds.
20
30
  const inFlight = new Map();
21
- // Shared by every member: load, fold, throw `Unknown DID` when absent. The whole state array
22
- // rather than the head, because `resolveHistoric` answers about rotated-away keys; head-only
23
- // members take `states[states.length - 1]`. Always the async fold: it differs from `foldLog` only
24
- // in awaiting a capability-authorised revoke's verifier, so a mode-selecting option would choose
25
- // nothing — the verifier is the only thing a caller supplies.
26
- async function computeStates(did) {
27
- const events = await options.loadLog(did);
28
- if (events == null || events.length === 0) {
31
+ // Shared by every member: load, fold, throw `Unknown DID` when absent. The chain's states as well
32
+ // as the head, because `resolveHistoric` answers about rotated-away keys.
33
+ async function computeView(did) {
34
+ const log = await options.loadLog(did);
35
+ if (log == null || isControllerLog(log) && log.chain.length === 0) {
29
36
  throw new Error(`Unknown DID: ${did}`);
30
37
  }
31
- const foldOptions = {
32
- verifyCapability: options.verifyCapability
33
- };
38
+ // Without a store, the same fold against no memory: the request limit, the chain, then the
39
+ // epoch — one path, so the two configurations cannot disagree about what a log means.
34
40
  if (options.history == null) {
35
- return await allStatesAsync({
41
+ return authoritativeLogWithin({
36
42
  did,
37
- events,
38
- context: CONTEXT,
39
- options: foldOptions
40
- });
43
+ loaded: log,
44
+ seen: undefined,
45
+ context: CONTEXT
46
+ }, limits).result;
41
47
  }
42
48
  // Compared against what this party last accepted, so a truncated log — folding cleanly, missing
43
49
  // the revoke that matters — is refused. See `LogStore`.
44
- const seen = await options.history.get(did);
45
- const { log, states } = await authoritativeStates({
46
- did,
47
- loaded: events,
48
- seen,
49
- context: CONTEXT,
50
- options: foldOptions
50
+ // Inside the store's merge, so two resolutions of one DID never lose each other's events. A log
51
+ // that lost throws out of the update, so nothing is written and the store never moves backwards.
52
+ // The answer is the merged fold — what was seen plus what was loaded — not the presentation.
53
+ let view;
54
+ await options.history.merge(did, async (seen)=>{
55
+ const settled = authoritativeLogWithin({
56
+ did,
57
+ loaded: log,
58
+ seen,
59
+ context: CONTEXT
60
+ }, limits);
61
+ view = settled.result;
62
+ return settled.log;
51
63
  });
52
- // Only after it has folded, and only in the memory-keeping direction: a log that lost never
53
- // reaches here, so the store never moves backwards.
54
- await options.history.set(did, log);
55
- return states;
64
+ if (view === undefined) {
65
+ throw new Error(`${CONTEXT}: the log store did not run the merge for ${did}`);
66
+ }
67
+ return view;
56
68
  }
57
- async function loadStates(did) {
69
+ async function loadView(did) {
58
70
  if (!did.startsWith(DID_PREFIX)) {
59
71
  throw new Error(`Unknown DID: ${did}`);
60
72
  }
@@ -67,7 +79,7 @@ const CONTEXT = 'Controller resolver';
67
79
  // Deferred by a microtask so the entry is registered *before* `loadLog` runs — otherwise a
68
80
  // `loadLog` that re-enters `resolve` before its own first `await` recurses to a stack overflow
69
81
  // while the map is still empty.
70
- const fold = Promise.resolve().then(()=>computeStates(did));
82
+ const fold = Promise.resolve().then(()=>computeView(did));
71
83
  inFlight.set(did, fold);
72
84
  try {
73
85
  return await fold;
@@ -79,43 +91,27 @@ const CONTEXT = 'Controller resolver';
79
91
  }
80
92
  }
81
93
  return {
82
- method: DID_METHOD,
83
- // The head's key set only: a profile that rotated away from a key answers "no" for it here, which
84
- // is what makes `rotate` retire a leaked authority key rather than add one beside it.
85
- async resolve (did, header = {}) {
86
- return signingKeyFrom({
87
- did,
88
- states: await loadStates(did),
89
- header
90
- });
91
- },
92
- // Every key set within the current generation, for a caller verifying past-issued material. A
93
- // `rotate` must not invalidate those; a `reset` still does, and the scan stops at the generation
94
- // boundary. See `signingKeyFrom` in `state-resolver.ts`.
95
- async resolveHistoric (did, header = {}) {
96
- return signingKeyFrom({
97
- did,
98
- states: await loadStates(did),
99
- header,
100
- historic: true
94
+ ...resolverOver(loadView),
95
+ frozen () {
96
+ // The promise, not the settled view: concurrent first calls for one DID join one load and
97
+ // fold, rather than each starting its own and answering from whichever settled.
98
+ const views = new Map();
99
+ return resolverOver((did)=>{
100
+ const known = views.get(did);
101
+ if (known !== undefined) {
102
+ return known;
103
+ }
104
+ const view = loadView(did);
105
+ views.set(did, view);
106
+ // A failed load is no answer to freeze: dropped, so a later call loads afresh. Every answer
107
+ // this view gave for the DID came from that failure, so the next fold is still its first.
108
+ view.catch(()=>{
109
+ if (views.get(did) === view) {
110
+ views.delete(did);
111
+ }
112
+ });
113
+ return view;
101
114
  });
102
- },
103
- async resolveDenySet (did) {
104
- // The head's set, deliberately, not the state at any position a capability names: `iat` is
105
- // author-supplied and backdatable, so anchoring to it would let a revoked holder pick a
106
- // position at which it was not yet denied. The set is position-dependent *within the fold*; the
107
- // question a verifier asks is "is this grant valid now". Both spellings ride in it (see
108
- // `KeyState.deny`); the key half is enforced by `signingKeyFrom`, and the two forms cannot
109
- // collide. `loadStates` throws `Unknown DID` rather than answering an empty (= "nobody
110
- // revoked") set.
111
- const states = await loadStates(did);
112
- return states[states.length - 1].deny;
113
- },
114
- async resolveAgreementKey (did) {
115
- // The head's set only, unlike `resolve`. A retired agreement key is a downgrade, not a grant
116
- // the profile already made: the recipient decrypts old ciphertexts by re-deriving from the
117
- // seed, never by resolution.
118
- return agreementKeysFrom(did, await loadStates(did));
119
115
  }
120
116
  };
121
117
  }
@@ -1,26 +1,42 @@
1
- import { type DIDMethodResolver, type ResolvedAgreementKey, type ResolvedSigningKey, type ResolveIssuerHeader } from '@kokuin/token';
1
+ import { type DIDMethodResolver, type MemberQuery, type ResolvedAgreementKey, type ResolvedMember, type ResolvedSigningKey, type ResolveIssuerHeader } from '@kokuin/token';
2
2
  import type { KeyState } from './fold.js';
3
3
  /** The method segment of {@link DID_PREFIX}, so the two cannot drift. */
4
4
  export declare const DID_METHOD: string;
5
+ /**
6
+ * A folded log as every resolver member reads it: `states`, one per chain event, for the historic
7
+ * key scan; `head`, the last of them merged with the current epoch, for every live answer (keys,
8
+ * denials, members, saturation).
9
+ */
10
+ export type FoldedView = {
11
+ states: Array<KeyState>;
12
+ head: KeyState;
13
+ };
14
+ /**
15
+ * What every resolver member throws while the controller's current epoch is saturated, followed by
16
+ * `: ` and the DID. A plain error, not `IssuerKeyNotFoundError`: nothing about the key failed, the
17
+ * controller as a whole cannot be answered for, and `@kokuin/capability` denies on exactly that.
18
+ */
19
+ export declare const EPOCH_SATURATED = "controller epoch is saturated";
20
+ /** What `resolveMember` rejects a member with that is not live in the head's generation. */
21
+ export declare const MEMBER_NOT_EFFECTIVE = "not an effective management member";
5
22
  /**
6
23
  * What a token signed by a revoked key is rejected with. The `#`-prefixed key follows after `: `, so
7
24
  * match with `startsWith`.
8
25
  */
9
26
  export declare const KEY_REVOKED = "kid names a key the controller has revoked";
10
27
  /**
11
- * The signing key `header` names, out of an already-folded log. `historic` picks which question above
28
+ * The signing key `header` names, out of an already-folded view. `historic` picks which question above
12
29
  * is asked, and defaults to the safe one.
13
30
  *
14
- * **A revoked key answers neither question.** A `rev` naming `#<key>` retires a key for material it
15
- * has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
31
+ * **A revoked key answers neither question.** A deny entry naming `#<key>` retires a key for material
32
+ * it has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
16
33
  * resolved key through here, so the denial covers `verifyToken` on both `historic` settings, every
17
- * capability and revocation record `@kokuin/capability` verifies, and the fold's own cap-authorised
18
- * revoke. The historic arm is the live one: a revoked key is never in the head's `k` (the
19
- * `KeyState.deny` invariant), so only the backwards scan can select one, and without this
20
- * `resolveHistoric` would accept a leaked key forever.
34
+ * capability and revocation record `@kokuin/capability` verifies. The historic arm is the live one:
35
+ * a revoked key is never in the head's `k` (the `KeyState.deny` invariant), so only the backwards
36
+ * scan can select one, and without this `resolveHistoric` would accept a leaked key forever.
21
37
  *
22
- * The head arm cannot be reached from a log this package folded, but is not decoration: `states`
23
- * arrives from the caller, and the invariant is the fold's property, not the array's — a
38
+ * The head arm cannot be reached from a log this package folded, but is not decoration: `view`
39
+ * arrives from the caller, and the invariant is the fold's property, not the view's — a
24
40
  * caller-built state whose head publishes a key it also denies resolves to nothing here.
25
41
  *
26
42
  * **Evaluated against the head's deny set, never the position the key was selected at.** A denial is
@@ -28,33 +44,53 @@ export declare const KEY_REVOKED = "kid names a key the controller has revoked";
28
44
  * thief presents a token resolving at a position before the revoke. Deliberately *not* how a DID
29
45
  * denial behaves inside the fold, where `states[i]` is consulted at position `i` because "was this
30
46
  * author allowed to act *here*" is the whole question; the two rules answer different questions.
31
- * Inside the fold they coincide anyway: `createStateResolver` gets the prefix `states[0..i-1]`, so
32
- * its head *is* the position being verified.
47
+ * The head here is the chain's last state merged with the current epoch, so a key retired by an
48
+ * epoch revoke is refused as surely as one a settlement checkpointed.
33
49
  */
34
- export declare function signingKeyFrom({ did, states, header, historic, }: {
50
+ export declare function signingKeyFrom({ did, view, header, historic, }: {
35
51
  did: string;
36
- states: Array<KeyState>;
52
+ view: FoldedView;
37
53
  header: ResolveIssuerHeader;
38
54
  historic?: boolean;
39
55
  }): ResolvedSigningKey;
40
56
  /**
41
- * The head's key agreement set, minus anything it has revoked. A `rev` may name an agreement key as
42
- * readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
57
+ * The head's key agreement set, minus anything it has revoked. A deny entry may name an agreement key
58
+ * as readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
43
59
  * a *sender* encrypts to — encrypting to a revoked key is the one outcome a denial must prevent.
44
60
  *
45
61
  * Like the signing side, unreachable from a log this package folded (the `KeyState.deny` invariant)
46
- * but `states` arrives from the caller. Filtering to empty is the honest answer for a state that
62
+ * but `head` arrives from the caller. Filtering to empty is the honest answer for a state that
47
63
  * denies everything it publishes: `@kokuin/jwe` then encrypts nothing, the fail-closed direction.
48
64
  */
49
- export declare function agreementKeysFrom(did: string, states: Array<KeyState>): Array<ResolvedAgreementKey>;
65
+ export declare function agreementKeysFrom(did: string, head: KeyState): Array<ResolvedAgreementKey>;
50
66
  /**
51
- * A `DIDMethodResolver` answering for exactly one profile, out of already-folded state. The fold
52
- * hands one to a capability-authorised revoke's verifier, built from the states *before* the event
53
- * being verified — the prefix contract made unforgettable: the verifier no longer needs a
54
- * position-aware registry, because the fold supplies the answer. See `FoldOptions.verifyCapability`.
55
- *
56
- * Answers only for `did`; any other identifier is `Unknown DID`, so a caller merging this into a
57
- * wider registry falls back to its own resolver for another `did:kokuin:` delegate and never serves
58
- * one profile's state for another's. Not a cache: the states are the caller's, fixed at construction.
67
+ * The key and lease the head pins for one management member, or `IssuerKeyNotFoundError` carrying
68
+ * {@link MEMBER_NOT_EFFECTIVE}.
69
+ *
70
+ * Every condition is asked of the head alone, the "answer for now" the interface demands: the member
71
+ * must be present in it under the queried `add` digest, not removed and not targeted by a denial
72
+ * (`effective`, which the fold computes with the one denial predicate it uses everywhere), and the
73
+ * query's DID must be the member's under canonical DID equivalence — the long and short spellings of
74
+ * one `did:peer:4` are one member; any other DID is another, even one carrying the same key.
75
+ *
76
+ * **The generation is part of the identity.** A reset carrying a snapshot keeps each member's `add`
77
+ * reference, so incarnation alone cannot tell a grant minted before the reset from one minted after;
78
+ * only `gen` can. A query naming any generation but the head's is refused, which is what makes a
79
+ * reset kill every grant of the generation it replaces, carried members' included. The lease is
80
+ * reported, never judged: the log has no clock, and bounding a grant by it is the verifier's step.
81
+ *
82
+ * `IssuerKeyNotFoundError` throughout, for the reason `keyFromKid` gives: the controller resolved and
83
+ * what failed is a claim a token made about it, which must not read as the controller being
84
+ * unresolvable.
85
+ */
86
+ export declare function memberFrom(did: string, head: KeyState, query: MemberQuery): ResolvedMember;
87
+ /**
88
+ * A `DIDMethodResolver` answering every member out of the view `load` yields for the asked DID —
89
+ * the one shape the live resolver and its frozen views share, so the
90
+ * saturation refusal and the choice of which half of the view answers what are written once.
91
+ *
92
+ * **A saturated head answers nothing**, `resolveHistoric` included: the epoch's denials can no
93
+ * longer be read, so any answer could be one a denial withdrew. The chain still folds, so root can
94
+ * settle into a fresh epoch, and the refusal ends there.
59
95
  */
60
- export declare function createStateResolver(did: string, states: Array<KeyState>): DIDMethodResolver;
96
+ export declare function resolverOver(load: (did: string) => Promise<FoldedView>): DIDMethodResolver;
@@ -1,7 +1,13 @@
1
- import { IssuerKeyNotFoundError } from '@kokuin/token';
1
+ import { IssuerKeyNotFoundError, normalizeDID } from '@kokuin/token';
2
2
  import { DID_PREFIX, keyTarget } from './events.js';
3
3
  import { decodeKey } from './keys.js';
4
4
  /** The method segment of {@link DID_PREFIX}, so the two cannot drift. */ export const DID_METHOD = DID_PREFIX.slice('did:'.length, -1);
5
+ /**
6
+ * What every resolver member throws while the controller's current epoch is saturated, followed by
7
+ * `: ` and the DID. A plain error, not `IssuerKeyNotFoundError`: nothing about the key failed, the
8
+ * controller as a whole cannot be answered for, and `@kokuin/capability` denies on exactly that.
9
+ */ export const EPOCH_SATURATED = 'controller epoch is saturated';
10
+ /** What `resolveMember` rejects a member with that is not live in the head's generation. */ export const MEMBER_NOT_EFFECTIVE = 'not an effective management member';
5
11
  /**
6
12
  * The `kid` fragment's body, or `null` when the header names no key. Format is `#<the multibase key
7
13
  * exactly as it appears in `k`>`, matched against the folded key sets by membership — an index-based
@@ -32,11 +38,14 @@ import { decodeKey } from './keys.js';
32
38
  * a fall back to `keys[0]`**, which would check the signature against a key the token never claimed.
33
39
  * An absent `kid` resolves to the head's first key: volunteering one is not the same as accepting one
34
40
  * the token names. Past-issued material is {@link historicSigningKey}, via explicit `resolveHistoric`.
35
- */ function headSigningKey(did, states, kid) {
36
- const head = states[states.length - 1];
41
+ */ function headSigningKey(did, head, kid) {
37
42
  const key = keyFromKid(did, kid);
38
43
  if (key == null) {
39
- return head.keys[0];
44
+ const first = head.keys[0];
45
+ if (first === undefined) {
46
+ throw new IssuerKeyNotFoundError(`Controller ${did} has no signing key`);
47
+ }
48
+ return first;
40
49
  }
41
50
  if (!head.keys.includes(key)) {
42
51
  throw new IssuerKeyNotFoundError(`Controller ${did} kid names a key that is not current: ${kid}`);
@@ -55,14 +64,21 @@ import { decodeKey } from './keys.js';
55
64
  * decreases, so the backwards scan stops dead at the first earlier-generation state. Runs backwards
56
65
  * because the head's own key set is the common answer. A `kid` naming a never-published key, or one
57
66
  * from a superseded generation, is an error — never a fall back to `keys[0]`.
58
- */ function historicSigningKey(did, states, kid) {
59
- const head = states[states.length - 1];
67
+ */ function historicSigningKey(did, { states, head }, kid) {
60
68
  const key = keyFromKid(did, kid);
61
69
  if (key == null) {
62
- return head.keys[0];
70
+ const first = head.keys[0];
71
+ if (first === undefined) {
72
+ throw new IssuerKeyNotFoundError(`Controller ${did} has no signing key`);
73
+ }
74
+ return first;
63
75
  }
64
- for(let i = states.length - 1; i >= 0 && states[i].gen === head.gen; i--){
65
- if (states[i].keys.includes(key)) {
76
+ for(let i = states.length - 1; i >= 0; i--){
77
+ const state = states[i];
78
+ if (state === undefined || state.gen !== head.gen) {
79
+ break;
80
+ }
81
+ if (state.keys.includes(key)) {
66
82
  return key;
67
83
  }
68
84
  }
@@ -73,19 +89,18 @@ import { decodeKey } from './keys.js';
73
89
  * match with `startsWith`.
74
90
  */ export const KEY_REVOKED = 'kid names a key the controller has revoked';
75
91
  /**
76
- * The signing key `header` names, out of an already-folded log. `historic` picks which question above
92
+ * The signing key `header` names, out of an already-folded view. `historic` picks which question above
77
93
  * is asked, and defaults to the safe one.
78
94
  *
79
- * **A revoked key answers neither question.** A `rev` naming `#<key>` retires a key for material it
80
- * has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
95
+ * **A revoked key answers neither question.** A deny entry naming `#<key>` retires a key for material
96
+ * it has *already* signed, and this is where that bites — every `did:kokuin:` signature check reaches a
81
97
  * resolved key through here, so the denial covers `verifyToken` on both `historic` settings, every
82
- * capability and revocation record `@kokuin/capability` verifies, and the fold's own cap-authorised
83
- * revoke. The historic arm is the live one: a revoked key is never in the head's `k` (the
84
- * `KeyState.deny` invariant), so only the backwards scan can select one, and without this
85
- * `resolveHistoric` would accept a leaked key forever.
98
+ * capability and revocation record `@kokuin/capability` verifies. The historic arm is the live one:
99
+ * a revoked key is never in the head's `k` (the `KeyState.deny` invariant), so only the backwards
100
+ * scan can select one, and without this `resolveHistoric` would accept a leaked key forever.
86
101
  *
87
- * The head arm cannot be reached from a log this package folded, but is not decoration: `states`
88
- * arrives from the caller, and the invariant is the fold's property, not the array's — a
102
+ * The head arm cannot be reached from a log this package folded, but is not decoration: `view`
103
+ * arrives from the caller, and the invariant is the fold's property, not the view's — a
89
104
  * caller-built state whose head publishes a key it also denies resolves to nothing here.
90
105
  *
91
106
  * **Evaluated against the head's deny set, never the position the key was selected at.** A denial is
@@ -93,14 +108,14 @@ import { decodeKey } from './keys.js';
93
108
  * thief presents a token resolving at a position before the revoke. Deliberately *not* how a DID
94
109
  * denial behaves inside the fold, where `states[i]` is consulted at position `i` because "was this
95
110
  * author allowed to act *here*" is the whole question; the two rules answer different questions.
96
- * Inside the fold they coincide anyway: `createStateResolver` gets the prefix `states[0..i-1]`, so
97
- * its head *is* the position being verified.
98
- */ export function signingKeyFrom({ did, states, header, historic = false }) {
99
- const head = states[states.length - 1];
111
+ * The head here is the chain's last state merged with the current epoch, so a key retired by an
112
+ * epoch revoke is refused as surely as one a settlement checkpointed.
113
+ */ export function signingKeyFrom({ did, view, header, historic = false }) {
114
+ const { head } = view;
100
115
  if (head.keys.length === 0) {
101
116
  throw new Error(`Controller ${did} has no signing key`);
102
117
  }
103
- const selected = historic ? historicSigningKey(did, states, header.kid) : headSigningKey(did, states, header.kid);
118
+ const selected = historic ? historicSigningKey(did, view, header.kid) : headSigningKey(did, head, header.kid);
104
119
  // `IssuerKeyNotFoundError` like every rejection here: the DID resolved and the log folded, and what
105
120
  // failed is the key. Retyping it unresolvable would let a `kid` (unauthenticated) make this
106
121
  // controller read as unresolvable to every fail-closed caller. See {@link keyFromKid}.
@@ -117,15 +132,14 @@ import { decodeKey } from './keys.js';
117
132
  };
118
133
  }
119
134
  /**
120
- * The head's key agreement set, minus anything it has revoked. A `rev` may name an agreement key as
121
- * readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
135
+ * The head's key agreement set, minus anything it has revoked. A deny entry may name an agreement key
136
+ * as readily as a signing one (the target spelling is the key, not its purpose), and these are the keys
122
137
  * a *sender* encrypts to — encrypting to a revoked key is the one outcome a denial must prevent.
123
138
  *
124
139
  * Like the signing side, unreachable from a log this package folded (the `KeyState.deny` invariant)
125
- * but `states` arrives from the caller. Filtering to empty is the honest answer for a state that
140
+ * but `head` arrives from the caller. Filtering to empty is the honest answer for a state that
126
141
  * denies everything it publishes: `@kokuin/jwe` then encrypts nothing, the fail-closed direction.
127
- */ export function agreementKeysFrom(did, states) {
128
- const head = states[states.length - 1];
142
+ */ export function agreementKeysFrom(did, head) {
129
143
  return head.agreement.flatMap((value)=>{
130
144
  if (head.deny.has(keyTarget(value))) {
131
145
  return [];
@@ -141,46 +155,109 @@ import { decodeKey } from './keys.js';
141
155
  });
142
156
  }
143
157
  /**
144
- * A `DIDMethodResolver` answering for exactly one profile, out of already-folded state. The fold
145
- * hands one to a capability-authorised revoke's verifier, built from the states *before* the event
146
- * being verified — the prefix contract made unforgettable: the verifier no longer needs a
147
- * position-aware registry, because the fold supplies the answer. See `FoldOptions.verifyCapability`.
158
+ * The key and lease the head pins for one management member, or `IssuerKeyNotFoundError` carrying
159
+ * {@link MEMBER_NOT_EFFECTIVE}.
160
+ *
161
+ * Every condition is asked of the head alone, the "answer for now" the interface demands: the member
162
+ * must be present in it under the queried `add` digest, not removed and not targeted by a denial
163
+ * (`effective`, which the fold computes with the one denial predicate it uses everywhere), and the
164
+ * query's DID must be the member's under canonical DID equivalence — the long and short spellings of
165
+ * one `did:peer:4` are one member; any other DID is another, even one carrying the same key.
166
+ *
167
+ * **The generation is part of the identity.** A reset carrying a snapshot keeps each member's `add`
168
+ * reference, so incarnation alone cannot tell a grant minted before the reset from one minted after;
169
+ * only `gen` can. A query naming any generation but the head's is refused, which is what makes a
170
+ * reset kill every grant of the generation it replaces, carried members' included. The lease is
171
+ * reported, never judged: the log has no clock, and bounding a grant by it is the verifier's step.
172
+ *
173
+ * `IssuerKeyNotFoundError` throughout, for the reason `keyFromKid` gives: the controller resolved and
174
+ * what failed is a claim a token made about it, which must not read as the controller being
175
+ * unresolvable.
176
+ */ export function memberFrom(did, head, query) {
177
+ const refuse = (why)=>new IssuerKeyNotFoundError(`Controller ${did} ${query.did} is ${MEMBER_NOT_EFFECTIVE}: ${why}`);
178
+ if (query.gen !== head.gen) {
179
+ throw refuse(`generation ${String(query.gen)} is not the current ${head.gen}`);
180
+ }
181
+ const member = head.members.get(query.add);
182
+ if (member === undefined) {
183
+ throw refuse(`no member was added by ${query.add}`);
184
+ }
185
+ if (!member.effective) {
186
+ throw refuse(member.removed ? 'removed' : 'denied');
187
+ }
188
+ if (normalizeDID(member.did) !== normalizeDID(query.did)) {
189
+ throw refuse(`incarnation ${query.add} is another DID`);
190
+ }
191
+ const key = decodeKey(member.key);
192
+ // Unreachable from a log this package folded (a member pin is Ed25519, adds and snapshots alike),
193
+ // but `head` may be caller-built, and an agreement key must never come back as a signing one.
194
+ if (key.alg !== 'EdDSA') {
195
+ throw refuse(`pinned key is not Ed25519: ${key.alg}`);
196
+ }
197
+ return {
198
+ key: {
199
+ alg: key.alg,
200
+ publicKey: key.publicKey
201
+ },
202
+ lease: member.lease
203
+ };
204
+ }
205
+ /**
206
+ * A `DIDMethodResolver` answering every member out of the view `load` yields for the asked DID —
207
+ * the one shape the live resolver and its frozen views share, so the
208
+ * saturation refusal and the choice of which half of the view answers what are written once.
148
209
  *
149
- * Answers only for `did`; any other identifier is `Unknown DID`, so a caller merging this into a
150
- * wider registry falls back to its own resolver for another `did:kokuin:` delegate and never serves
151
- * one profile's state for another's. Not a cache: the states are the caller's, fixed at construction.
152
- */ export function createStateResolver(did, states) {
153
- function statesFor(asked) {
154
- if (asked !== did) {
155
- throw new Error(`Unknown DID: ${asked}`);
210
+ * **A saturated head answers nothing**, `resolveHistoric` included: the epoch's denials can no
211
+ * longer be read, so any answer could be one a denial withdrew. The chain still folds, so root can
212
+ * settle into a fresh epoch, and the refusal ends there.
213
+ */ export function resolverOver(load) {
214
+ async function live(did) {
215
+ const view = await load(did);
216
+ if (view.head.saturated) {
217
+ throw new Error(`${EPOCH_SATURATED}: ${did}`);
156
218
  }
157
- return states;
219
+ return view;
158
220
  }
159
221
  return {
160
222
  method: DID_METHOD,
161
- async resolve (asked, header = {}) {
223
+ // The head's key set only: a profile that rotated away from a key answers "no" for it here, which
224
+ // is what makes `rotate` retire a leaked authority key rather than add one beside it.
225
+ async resolve (did, header = {}) {
162
226
  return signingKeyFrom({
163
- did: asked,
164
- states: statesFor(asked),
227
+ did,
228
+ view: await live(did),
165
229
  header
166
230
  });
167
231
  },
168
- // The head of *this prefix*, not the log: these states end at the position the fold is verifying.
169
- // A capability issued before that position is the archived material `resolveHistoric` exists for.
170
- async resolveHistoric (asked, header = {}) {
232
+ // Every key set within the current generation, for a caller verifying past-issued material. A
233
+ // `rotate` must not invalidate those; a `reset` still does, and the scan stops at the generation
234
+ // boundary. See {@link signingKeyFrom}.
235
+ async resolveHistoric (did, header = {}) {
171
236
  return signingKeyFrom({
172
- did: asked,
173
- states: statesFor(asked),
237
+ did,
238
+ view: await live(did),
174
239
  header,
175
240
  historic: true
176
241
  });
177
242
  },
178
- async resolveDenySet (asked) {
179
- const known = statesFor(asked);
180
- return known[known.length - 1].deny;
243
+ async resolveDenySet (did) {
244
+ // The head's set, deliberately, not the state at any position a capability names: `iat` is
245
+ // author-supplied and backdatable, so anchoring to it would let a revoked holder pick a
246
+ // position at which it was not yet denied. The head merges the current epoch, so a denial
247
+ // made there without root counts as soon as this party has seen it. Both spellings ride in it
248
+ // (see `KeyState.deny`); the key half is enforced by `signingKeyFrom`, and the two forms
249
+ // cannot collide. `load` throws `Unknown DID` rather than answering an empty (= "nobody
250
+ // revoked") set.
251
+ return (await live(did)).head.deny;
252
+ },
253
+ async resolveAgreementKey (did) {
254
+ // The head's set only, unlike `resolve`. A retired agreement key is a downgrade, not a grant
255
+ // the profile already made: the recipient decrypts old ciphertexts by re-deriving from the
256
+ // seed, never by resolution.
257
+ return agreementKeysFrom(did, (await live(did)).head);
181
258
  },
182
- async resolveAgreementKey (asked) {
183
- return agreementKeysFrom(asked, statesFor(asked));
259
+ async resolveMember (subject, member) {
260
+ return memberFrom(subject, (await live(subject)).head, member);
184
261
  }
185
262
  };
186
263
  }
package/lib/state.d.ts CHANGED
@@ -1,28 +1,12 @@
1
- import type { SignedEvent } from './events.js';
2
- import { type FoldOptions, type KeyState } from './fold.js';
1
+ import { type KeyState } from './fold.js';
2
+ import { type ControllerLog } from './log.js';
3
3
  /**
4
- * Current state via the synchronous {@link foldLog}. A capability-authorised revoke fails closed
5
- * here — see {@link currentStateAsync}.
4
+ * The root key state a log establishes: the chain's last state.
5
+ *
6
+ * The chain alone, deliberately. Epoch events never change a root key (`k`, `ka`, `n` and the
7
+ * derivation indices live on the chain only), so folding them would buy an identity nothing but up
8
+ * to `MAX_EPOCH_EVENTS` signature checks on a path that must stay synchronous and cheap. The returned
9
+ * state's `deny`, `members` and `saturated` are therefore the chain's, not the live ones; nothing
10
+ * that builds an identity reads them.
6
11
  */
7
- export declare function currentState(did: string, events: Array<SignedEvent>, context: string): KeyState;
8
- /**
9
- * Every per-position state, via {@link foldLogAsync}. What the resolver needs and the identity does
10
- * not: a verifier has to answer about keys the log has since rotated away, since a token outlives the
11
- * state that gave its `kid` meaning.
12
- */
13
- export declare function allStatesAsync({ did, events, context, options, }: {
14
- did: string;
15
- events: Array<SignedEvent>;
16
- context: string;
17
- options?: FoldOptions;
18
- }): Promise<Array<KeyState>>;
19
- /**
20
- * Current state via {@link foldLogAsync}, which can await `options.verifyCapability` for a
21
- * capability-authorised revoke instead of failing closed on it.
22
- */
23
- export declare function currentStateAsync({ did, events, context, options, }: {
24
- did: string;
25
- events: Array<SignedEvent>;
26
- context: string;
27
- options?: FoldOptions;
28
- }): Promise<KeyState>;
12
+ export declare function currentState(did: string, log: ControllerLog, context: string): KeyState;