@interop/wallet-core 0.45.0 → 0.47.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/dist/keyring/record.d.ts +4 -3
- package/dist/keyring/record.d.ts.map +1 -1
- package/dist/keyring/record.js.map +1 -1
- package/dist/keys/rosterLogStore.d.ts.map +1 -1
- package/dist/keys/rosterLogStore.js +18 -1
- package/dist/keys/rosterLogStore.js.map +1 -1
- package/dist/keys/rosterStore.d.ts +25 -1
- package/dist/keys/rosterStore.d.ts.map +1 -1
- package/dist/keys/rosterStore.js +6 -2
- package/dist/keys/rosterStore.js.map +1 -1
- package/dist/recovery/index.d.ts +1 -1
- package/dist/recovery/index.d.ts.map +1 -1
- package/dist/recovery/index.js +1 -1
- package/dist/recovery/index.js.map +1 -1
- package/dist/recovery/recoveryDelegation.d.ts +27 -40
- package/dist/recovery/recoveryDelegation.d.ts.map +1 -1
- package/dist/recovery/recoveryDelegation.js +119 -53
- package/dist/recovery/recoveryDelegation.js.map +1 -1
- package/dist/resourceLog/controller.d.ts +38 -3
- package/dist/resourceLog/controller.d.ts.map +1 -1
- package/dist/resourceLog/controller.js +106 -10
- package/dist/resourceLog/controller.js.map +1 -1
- package/dist/resourceLog/errors.d.ts +22 -1
- package/dist/resourceLog/errors.d.ts.map +1 -1
- package/dist/resourceLog/errors.js +23 -1
- package/dist/resourceLog/errors.js.map +1 -1
- package/dist/resourceLog/index.d.ts +8 -6
- package/dist/resourceLog/index.d.ts.map +1 -1
- package/dist/resourceLog/index.js +7 -5
- package/dist/resourceLog/index.js.map +1 -1
- package/dist/resourceLog/license.d.ts +44 -0
- package/dist/resourceLog/license.d.ts.map +1 -0
- package/dist/resourceLog/license.js +63 -0
- package/dist/resourceLog/license.js.map +1 -0
- package/dist/resourceLog/verify.d.ts +3 -2
- package/dist/resourceLog/verify.d.ts.map +1 -1
- package/dist/resourceLog/verify.js +30 -8
- package/dist/resourceLog/verify.js.map +1 -1
- package/dist/unlock/index.d.ts +17 -8
- package/dist/unlock/index.d.ts.map +1 -1
- package/dist/unlock/index.js +17 -8
- package/dist/unlock/index.js.map +1 -1
- package/dist/unlock/ladder.d.ts +76 -0
- package/dist/unlock/ladder.d.ts.map +1 -1
- package/dist/unlock/ladder.js +111 -1
- package/dist/unlock/ladder.js.map +1 -1
- package/dist/unlock/standingWebvh.d.ts +39 -0
- package/dist/unlock/standingWebvh.d.ts.map +1 -1
- package/dist/unlock/standingWebvh.js +69 -4
- package/dist/unlock/standingWebvh.js.map +1 -1
- package/dist/unlock/unlockRecord.d.ts +28 -8
- package/dist/unlock/unlockRecord.d.ts.map +1 -1
- package/dist/unlock/unlockRecord.js +93 -13
- package/dist/unlock/unlockRecord.js.map +1 -1
- package/dist/webvh/companion.d.ts +681 -0
- package/dist/webvh/companion.d.ts.map +1 -0
- package/dist/webvh/companion.js +1151 -0
- package/dist/webvh/companion.js.map +1 -0
- package/dist/webvh/delegatedLogStore.d.ts +67 -0
- package/dist/webvh/delegatedLogStore.d.ts.map +1 -0
- package/dist/webvh/delegatedLogStore.js +104 -0
- package/dist/webvh/delegatedLogStore.js.map +1 -0
- package/dist/webvh/didWebvh.d.ts +109 -5
- package/dist/webvh/didWebvh.d.ts.map +1 -1
- package/dist/webvh/didWebvh.js +186 -21
- package/dist/webvh/didWebvh.js.map +1 -1
- package/dist/webvh/index.d.ts +34 -5
- package/dist/webvh/index.d.ts.map +1 -1
- package/dist/webvh/index.js +31 -5
- package/dist/webvh/index.js.map +1 -1
- package/dist/webvh/listClients.d.ts +33 -0
- package/dist/webvh/listClients.d.ts.map +1 -1
- package/dist/webvh/listClients.js +27 -0
- package/dist/webvh/listClients.js.map +1 -1
- package/dist/webvh/standingZcap.d.ts +48 -0
- package/dist/webvh/standingZcap.d.ts.map +1 -0
- package/dist/webvh/standingZcap.js +54 -0
- package/dist/webvh/standingZcap.js.map +1 -0
- package/dist/webvh/wasIdStore.d.ts +36 -6
- package/dist/webvh/wasIdStore.d.ts.map +1 -1
- package/dist/webvh/wasIdStore.js +32 -13
- package/dist/webvh/wasIdStore.js.map +1 -1
- package/dist/webvh/zcap.d.ts +24 -0
- package/dist/webvh/zcap.d.ts.map +1 -1
- package/dist/webvh/zcap.js +42 -0
- package/dist/webvh/zcap.js.map +1 -1
- package/package.json +9 -9
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Interop Alliance. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* The companion did:webvh: the disposable sidecar log holding transient
|
|
6
|
+
* per-visit verification methods, one generation per flat `gen-` collection
|
|
7
|
+
* inside the account's stable auxiliary companion Space -- so per-visit facts
|
|
8
|
+
* stay out of the account's identity log entirely. This module is the
|
|
9
|
+
* generation's identity, genesis, and enrollment machinery: the
|
|
10
|
+
* `gen-<random>` segment convention, the typed auxiliary Space ensure, the
|
|
11
|
+
* genesis parameters, the pin-slot key for companion continuity, the atomic
|
|
12
|
+
* transient-enrollment entry, and the account document's delegated-clients
|
|
13
|
+
* service entry (the pointer at the current generation).
|
|
14
|
+
*
|
|
15
|
+
* The companion's posture differs from the account log's on purpose:
|
|
16
|
+
*
|
|
17
|
+
* - Update authority is each standing credential's static companion rung 0
|
|
18
|
+
* (chain length one, no rung advancement, no attribution scan). Genesis
|
|
19
|
+
* states the minting credential's rung-0 key in `updateKeys` and commits
|
|
20
|
+
* every standing credential's rung-0 hash in `nextKeyHashes` -- the minting
|
|
21
|
+
* key's own carry-over hash included, or no later entry could re-state it.
|
|
22
|
+
* - Prerotation stays on (the rung-0 hashes are the commitment chain),
|
|
23
|
+
* witnesses stay off, and portability is off: a companion is
|
|
24
|
+
* generation-scoped and host-bound, and replacement is a GC swap, never a
|
|
25
|
+
* portability move.
|
|
26
|
+
* - The genesis document is bare -- no verification methods, no service
|
|
27
|
+
* entries, the DID core context only. Transient verification methods are
|
|
28
|
+
* published per visit by later entries, and the generation delegation's
|
|
29
|
+
* service entry is installed by the entry publishing the generation's first
|
|
30
|
+
* transient method, never by genesis (its bytes would have to name the SCID
|
|
31
|
+
* the genesis hash derives from).
|
|
32
|
+
* - No `did:web` projection exists: the generation collection holds only its
|
|
33
|
+
* `did.jsonl`, capability-gated rather than world-readable.
|
|
34
|
+
*
|
|
35
|
+
* Ordering rule: the companion log publishes FIRST; only then does the
|
|
36
|
+
* caller re-point the account document's `#DelegatedClients` service entry at
|
|
37
|
+
* the new companion DID. A companion nobody points at is authorization-inert
|
|
38
|
+
* (no delegation ever names it), so a tear or a double-genesis race leaks
|
|
39
|
+
* storage, never authority -- and the standing orphan discovery is a plain
|
|
40
|
+
* `gen-` prefix match over the auxiliary Space's collection listing, with no
|
|
41
|
+
* registry of generations anywhere.
|
|
42
|
+
*
|
|
43
|
+
* Generation identity is random, never a counter: a reused segment would
|
|
44
|
+
* re-derive the same rung-0 update key for a new generation, and no counter
|
|
45
|
+
* carrier survives GC deleting the old collection. The segment is also the
|
|
46
|
+
* generation-identifying half of the companion rung HKDF labels
|
|
47
|
+
* (`<segment>/rung/<k>` under the unlock ladder's one salt), so there is
|
|
48
|
+
* exactly one spelling of a generation's identity -- the one the companion
|
|
49
|
+
* DID string already embeds.
|
|
50
|
+
*/
|
|
51
|
+
import { createDID, deriveNextKeyHash, updateDID } from '@interop/did-method-webvh';
|
|
52
|
+
import { rootCapabilityId, spaceItems, spacePath, toUrl } from '@interop/was-client/paths';
|
|
53
|
+
import { base64urlnopad } from '@scure/base';
|
|
54
|
+
import { DID_LOG_RESOURCE } from '../space/collections.js';
|
|
55
|
+
import { resourceLogPinId } from '../resourceLog/pin.js';
|
|
56
|
+
import { companionRung } from '../unlock/ladder.js';
|
|
57
|
+
import { assertCarryOverCommitments, concludeWithPublishedLog, didWebvhControllerTemplate, MULTIKEY_VM_TYPE, publishUpdatedLog, putLogResource, readPublishedLog, relationIds, updateKeyMultibase, updateKeySigner, withLogConflictRetry } from './didWebvh.js';
|
|
58
|
+
import { STANDING_ZCAP_TTL_MS, zcapExpiring } from './standingZcap.js';
|
|
59
|
+
import { wasWebvhLogStore } from './wasIdStore.js';
|
|
60
|
+
/**
|
|
61
|
+
* The Space Description `type` array of the auxiliary companion Space, set at
|
|
62
|
+
* creation (the server treats a Space's `type` as immutable afterwards).
|
|
63
|
+
* Wire-level and permanent: the server's inspector clause recognizes the
|
|
64
|
+
* `DelegatedClientsSpace` member, and user-data surfaces exclude auxiliary
|
|
65
|
+
* Spaces by it.
|
|
66
|
+
*/
|
|
67
|
+
export const COMPANION_SPACE_TYPE = [
|
|
68
|
+
'Space',
|
|
69
|
+
'AuxiliarySpace',
|
|
70
|
+
'DelegatedClientsSpace'
|
|
71
|
+
];
|
|
72
|
+
/**
|
|
73
|
+
* The `type` member that marks a Space as the delegated-clients auxiliary
|
|
74
|
+
* Space (the last entry of {@link COMPANION_SPACE_TYPE}).
|
|
75
|
+
*/
|
|
76
|
+
const DELEGATED_CLIENTS_SPACE_TYPE = 'DelegatedClientsSpace';
|
|
77
|
+
/**
|
|
78
|
+
* The literal prefix of every generation collection's name. Wire-level and
|
|
79
|
+
* permanent: orphan discovery is a plain prefix match over the auxiliary
|
|
80
|
+
* Space's collection listing, and the segment embeds in every companion DID
|
|
81
|
+
* string ever published.
|
|
82
|
+
*/
|
|
83
|
+
export const GENERATION_SEGMENT_PREFIX = 'gen-';
|
|
84
|
+
/**
|
|
85
|
+
* The random suffix: 12 bytes, base64url-no-pad (16 characters), for 20
|
|
86
|
+
* characters total. Every character is inside the server's `[A-Za-z0-9._~-]+`
|
|
87
|
+
* id allowlist, so `encodeURIComponent` is the identity on the segment and
|
|
88
|
+
* the DID path encoding round-trips it.
|
|
89
|
+
*/
|
|
90
|
+
const GENERATION_SEGMENT_SUFFIX_BYTES = 12;
|
|
91
|
+
/**
|
|
92
|
+
* The full segment shape: the literal prefix plus 16 base64url characters.
|
|
93
|
+
*/
|
|
94
|
+
const GENERATION_SEGMENT_PATTERN = /^gen-[A-Za-z0-9_-]{16}$/;
|
|
95
|
+
/**
|
|
96
|
+
* Mints a fresh generation segment -- the generation collection's name, e.g.
|
|
97
|
+
* `gen-Ux3v0kQf9aPmB2hZ`. Random rather than a counter on purpose: never-reuse
|
|
98
|
+
* is structural (nothing durable survives GC to carry a counter), at the same
|
|
99
|
+
* probabilistic order as every other random-id convention in the system.
|
|
100
|
+
*
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function mintGenerationSegment() {
|
|
104
|
+
return (GENERATION_SEGMENT_PREFIX +
|
|
105
|
+
base64urlnopad.encode(crypto.getRandomValues(new Uint8Array(GENERATION_SEGMENT_SUFFIX_BYTES))));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Refuses anything that is not a well-formed generation segment. Run by every
|
|
109
|
+
* companion builder that takes a segment, so a malformed one is refused
|
|
110
|
+
* before it can reach a DID string, an HKDF label, or a collection id.
|
|
111
|
+
*
|
|
112
|
+
* @param segment {string}
|
|
113
|
+
*/
|
|
114
|
+
export function assertGenerationSegment(segment) {
|
|
115
|
+
if (!GENERATION_SEGMENT_PATTERN.test(segment)) {
|
|
116
|
+
throw new Error(`Not a generation segment: "${segment}" (expected "gen-" plus 16 ` +
|
|
117
|
+
'base64url characters).');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The pin-slot key for one companion generation's log -- host-free like every
|
|
122
|
+
* pin-slot key, keyed by the auxiliary Space id and the generation segment.
|
|
123
|
+
* A transient session keeps this slot in an in-memory pin store (a durable
|
|
124
|
+
* pin is the wrong lifetime for a disposable log, and a transient session
|
|
125
|
+
* must not durably create the pin store on a read); a durable client's store
|
|
126
|
+
* clears companion slots when the generation is collected.
|
|
127
|
+
*
|
|
128
|
+
* @param options {object}
|
|
129
|
+
* @param options.spaceId {string} the auxiliary companion Space's id
|
|
130
|
+
* @param options.segment {string} the generation collection's name
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
export function companionLogPinId({ spaceId, segment }) {
|
|
134
|
+
return resourceLogPinId({
|
|
135
|
+
spaceId,
|
|
136
|
+
collectionId: segment,
|
|
137
|
+
resourceId: DID_LOG_RESOURCE
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The WAS-backed store a companion generation's ceremonies read and publish
|
|
142
|
+
* through with controller-tier signing (an enrolled client). A transient
|
|
143
|
+
* session writes through the delegated store instead
|
|
144
|
+
* (`delegatedWebvhLogStore`, invoking the credential's sibling delegation);
|
|
145
|
+
* both carry the same CAS/ETag conditional-publish discipline.
|
|
146
|
+
*
|
|
147
|
+
* @param options {object}
|
|
148
|
+
* @param options.was {WasClient}
|
|
149
|
+
* @param options.spaceId {string} the auxiliary companion Space's id
|
|
150
|
+
* @param options.segment {string} the generation collection's name
|
|
151
|
+
* @returns {WebvhLogResourceStore}
|
|
152
|
+
*/
|
|
153
|
+
export function companionLogStore({ was, spaceId, segment }) {
|
|
154
|
+
assertGenerationSegment(segment);
|
|
155
|
+
return wasWebvhLogStore({ was, spaceId, collectionId: segment });
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The DID core context -- the companion genesis document's whole `@context`.
|
|
159
|
+
* The document carries no verification methods and no service entries at
|
|
160
|
+
* genesis, so no other vocabulary is in scope; the entry that first publishes
|
|
161
|
+
* a typed member extends the context then (a did:webvh entry replaces the
|
|
162
|
+
* document wholesale).
|
|
163
|
+
*/
|
|
164
|
+
const DID_CORE_CONTEXT = 'https://www.w3.org/ns/did/v1';
|
|
165
|
+
/**
|
|
166
|
+
* The Multikey context, appended to the companion document's `@context` by
|
|
167
|
+
* the entry that first publishes a transient verification method (genesis
|
|
168
|
+
* carries the DID core context only, having no typed members to define).
|
|
169
|
+
*/
|
|
170
|
+
const MULTIKEY_CONTEXT_URL = 'https://w3id.org/security/multikey/v1';
|
|
171
|
+
/**
|
|
172
|
+
* Creates the one-entry companion generation log. The genesis parameters are
|
|
173
|
+
* the companion posture (see the module doc): prerotation on via the rung-0
|
|
174
|
+
* hash commitments, no witnesses, portability off (the library's default,
|
|
175
|
+
* stated explicitly in the emitted entry), and a bare document -- id and the
|
|
176
|
+
* DID core context, nothing else.
|
|
177
|
+
*
|
|
178
|
+
* The caller supplies the update authority: the minting credential's
|
|
179
|
+
* companion rung-0 key as the sole `updateKeys` member, `nextKeyHashes` as
|
|
180
|
+
* every standing credential's rung-0 hash (restated explicitly on every later
|
|
181
|
+
* entry, never inherited), and rung 0's signer. The minting key's own
|
|
182
|
+
* carry-over hash MUST be among the commitments -- every companion entry
|
|
183
|
+
* re-states `updateKeys` containing the revealed rung-0 keys, and the
|
|
184
|
+
* resolver checks the re-statement against the previous entry's commitments
|
|
185
|
+
* -- so a `nextKeyHashes` that omits it is refused here rather than
|
|
186
|
+
* publishing a generation no one can ever extend.
|
|
187
|
+
*
|
|
188
|
+
* @param options {object}
|
|
189
|
+
* @param options.wasServerUrl {string}
|
|
190
|
+
* @param options.spaceId {string} the auxiliary companion Space's id
|
|
191
|
+
* @param options.segment {string} the generation collection's name
|
|
192
|
+
* @param options.updateKeyPublicKeyMultibase {string} the minting
|
|
193
|
+
* credential's companion rung-0 key
|
|
194
|
+
* @param options.nextKeyHashes {string[]} every standing credential's
|
|
195
|
+
* rung-0 hash, the minting credential's included
|
|
196
|
+
* @param options.signer {Signer} the minting credential's rung-0 signer
|
|
197
|
+
* @returns {Promise<{ log: DIDLog; did: string; doc: DIDDoc }>}
|
|
198
|
+
*/
|
|
199
|
+
export async function createCompanionLog({ wasServerUrl, spaceId, segment, updateKeyPublicKeyMultibase, nextKeyHashes, signer }) {
|
|
200
|
+
assertGenerationSegment(segment);
|
|
201
|
+
const carryOverHash = await deriveNextKeyHash(updateKeyPublicKeyMultibase);
|
|
202
|
+
if (!nextKeyHashes.includes(carryOverHash)) {
|
|
203
|
+
throw new Error('companion genesis: `nextKeyHashes` must include the minting ' +
|
|
204
|
+
"credential's own rung-0 hash (the carry-over commitment), or no " +
|
|
205
|
+
'later entry could ever re-state the revealed key.');
|
|
206
|
+
}
|
|
207
|
+
const { host } = new URL(wasServerUrl);
|
|
208
|
+
const controllerTemplate = didWebvhControllerTemplate({
|
|
209
|
+
wasServerUrl,
|
|
210
|
+
spaceId,
|
|
211
|
+
collectionId: segment
|
|
212
|
+
});
|
|
213
|
+
const result = await createDID({
|
|
214
|
+
address: host,
|
|
215
|
+
paths: ['space', spaceId, segment],
|
|
216
|
+
signer,
|
|
217
|
+
updateKeys: [updateKeyPublicKeyMultibase],
|
|
218
|
+
nextKeyHashes,
|
|
219
|
+
didDocument: { '@context': [DID_CORE_CONTEXT], id: controllerTemplate }
|
|
220
|
+
});
|
|
221
|
+
if (!result.did || !result.doc) {
|
|
222
|
+
throw new Error('companion genesis: createDID returned no DID document.');
|
|
223
|
+
}
|
|
224
|
+
return { log: result.log, did: result.did, doc: result.doc };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Ensures the auxiliary companion Space exists: created with the typed
|
|
228
|
+
* Description ({@link COMPANION_SPACE_TYPE}) under the given controller when
|
|
229
|
+
* absent, verified when present. The `type` array must ride the create -- the
|
|
230
|
+
* server accepts it at creation only and treats it as immutable afterwards --
|
|
231
|
+
* which is also why an existing Space at this id that is NOT typed as the
|
|
232
|
+
* delegated-clients Space is refused loudly: it can never become one.
|
|
233
|
+
*
|
|
234
|
+
* The Space id is minted by the caller at credential bind time with the
|
|
235
|
+
* account Space's `mintSpaceId` convention (32 random bytes, base64url
|
|
236
|
+
* no-pad): the sibling delegation's `invocationTarget` embeds the id and is
|
|
237
|
+
* sealed into the unlock record before the account DID exists, so no
|
|
238
|
+
* derivation over the account identity is possible -- and hash-derived
|
|
239
|
+
* addressing would import the unlock Spaces' existence-oracle posture,
|
|
240
|
+
* unwanted here.
|
|
241
|
+
*
|
|
242
|
+
* @param options {object}
|
|
243
|
+
* @param options.was {WasClient}
|
|
244
|
+
* @param options.spaceId {string} the auxiliary companion Space's id
|
|
245
|
+
* @param options.controller {string} the Space controller (the account
|
|
246
|
+
* did:webvh where it exists; a bootstrap did:key on a client-less signup,
|
|
247
|
+
* promoted the same way the account Space's controller is)
|
|
248
|
+
* @returns {Promise<void>}
|
|
249
|
+
*/
|
|
250
|
+
export async function ensureCompanionSpace({ was, spaceId, controller }) {
|
|
251
|
+
const space = was.space(spaceId);
|
|
252
|
+
const current = await space.describe();
|
|
253
|
+
if (current === null) {
|
|
254
|
+
await space.configure({
|
|
255
|
+
controller,
|
|
256
|
+
type: COMPANION_SPACE_TYPE,
|
|
257
|
+
force: true
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (!current.type?.includes(DELEGATED_CLIENTS_SPACE_TYPE)) {
|
|
262
|
+
throw new Error(`The Space "${spaceId}" exists but is not typed as the ` +
|
|
263
|
+
'delegated-clients auxiliary Space; its type is immutable, so it ' +
|
|
264
|
+
'cannot hold companion generations.');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Mints a fresh companion generation with controller-tier signing: ensures
|
|
269
|
+
* the typed auxiliary Space, mints a fresh random segment, creates the
|
|
270
|
+
* generation collection, and publishes the genesis `did.jsonl` as a
|
|
271
|
+
* create-if-absent -- the same conditional-publish discipline as every log
|
|
272
|
+
* write, though a fresh random segment makes a create collision negligible.
|
|
273
|
+
*
|
|
274
|
+
* The account document's `#DelegatedClients` service entry is deliberately
|
|
275
|
+
* NOT written here: the companion log publishes first, and the caller
|
|
276
|
+
* re-points the account document at the returned DID afterwards. A run torn
|
|
277
|
+
* between the two leaves an unpointed generation -- authorization-inert (no
|
|
278
|
+
* delegation names it), collected by the standing `gen-` prefix orphan
|
|
279
|
+
* discovery at the next durable login.
|
|
280
|
+
*
|
|
281
|
+
* A re-run after a tear mints a FRESH generation rather than resuming: the
|
|
282
|
+
* genesis entry is timestamped, so a re-created log has a different SCID and
|
|
283
|
+
* a resume could never land its create-if-absent PUT; the torn generation is
|
|
284
|
+
* an inert orphan like any other.
|
|
285
|
+
*
|
|
286
|
+
* @param options {object}
|
|
287
|
+
* @param options.was {WasClient} the storage client, signing as an enrolled
|
|
288
|
+
* client (or the bootstrap controller on a client-less signup)
|
|
289
|
+
* @param options.wasServerUrl {string}
|
|
290
|
+
* @param options.spaceId {string} the auxiliary companion Space's id
|
|
291
|
+
* @param options.controller {string} the auxiliary Space's controller, used
|
|
292
|
+
* only when the Space does not exist yet
|
|
293
|
+
* @param options.updateKeyPublicKeyMultibase {string} the minting
|
|
294
|
+
* credential's companion rung-0 key
|
|
295
|
+
* @param options.nextKeyHashes {string[]} every standing credential's
|
|
296
|
+
* rung-0 hash, the minting credential's included
|
|
297
|
+
* @param options.signer {Signer} the minting credential's rung-0 signer
|
|
298
|
+
* @returns {Promise<{ did: string; segment: string; log: DIDLog;
|
|
299
|
+
* doc: DIDDoc }>}
|
|
300
|
+
*/
|
|
301
|
+
export async function mintCompanionGeneration({ was, wasServerUrl, spaceId, controller, updateKeyPublicKeyMultibase, nextKeyHashes, signer }) {
|
|
302
|
+
await ensureCompanionSpace({ was, spaceId, controller });
|
|
303
|
+
const segment = mintGenerationSegment();
|
|
304
|
+
// The generation collection must exist before its first resource PUT; a
|
|
305
|
+
// fresh random segment means this is always a create. Plaintext on purpose:
|
|
306
|
+
// the server resolves the companion DID out of its own storage, and the
|
|
307
|
+
// collection is capability-gated rather than encrypted.
|
|
308
|
+
await was
|
|
309
|
+
.space(spaceId)
|
|
310
|
+
.collection(segment, { encryption: 'plaintext' })
|
|
311
|
+
.configure({ name: segment, force: true });
|
|
312
|
+
const created = await createCompanionLog({
|
|
313
|
+
wasServerUrl,
|
|
314
|
+
spaceId,
|
|
315
|
+
segment,
|
|
316
|
+
updateKeyPublicKeyMultibase,
|
|
317
|
+
nextKeyHashes,
|
|
318
|
+
signer
|
|
319
|
+
});
|
|
320
|
+
await putLogResource({
|
|
321
|
+
store: companionLogStore({ was, spaceId, segment }),
|
|
322
|
+
log: created.log,
|
|
323
|
+
ifNoneMatch: true
|
|
324
|
+
});
|
|
325
|
+
return { ...created, segment };
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The type IRI of the account document's delegated-clients service entry --
|
|
329
|
+
* the pointer at the current companion generation's DID. Wire-level and
|
|
330
|
+
* permanent: readers (this module's {@link delegatedClientsPointer}, the
|
|
331
|
+
* server's companion-chain inspector clause) dispatch on this IRI, never on
|
|
332
|
+
* the entry's fragment id, which is non-semantic by convention.
|
|
333
|
+
*/
|
|
334
|
+
export const DELEGATED_CLIENTS_SERVICE_TYPE = 'https://w3id.org/byoe#DelegatedClients';
|
|
335
|
+
/**
|
|
336
|
+
* The fragment id the wallet mints for a fresh delegated-clients service
|
|
337
|
+
* entry. Non-semantic by the byoe service-entry convention -- readers MUST
|
|
338
|
+
* dispatch on {@link DELEGATED_CLIENTS_SERVICE_TYPE} -- and stable: the GC
|
|
339
|
+
* re-point preserves an existing entry's id verbatim, whatever it is.
|
|
340
|
+
*/
|
|
341
|
+
const DELEGATED_CLIENTS_SERVICE_FRAGMENT = 'delegated-clients';
|
|
342
|
+
/**
|
|
343
|
+
* Builds a fresh delegated-clients service entry for the account document.
|
|
344
|
+
* The `serviceEndpoint` is the companion DID STRING, deliberately not a URL:
|
|
345
|
+
* the DID is self-certifying and host-independent, and the account pointer
|
|
346
|
+
* already carries the host.
|
|
347
|
+
*
|
|
348
|
+
* @param options {object}
|
|
349
|
+
* @param options.accountDid {string} the account did:webvh
|
|
350
|
+
* @param options.companionDid {string} the current generation's companion
|
|
351
|
+
* DID
|
|
352
|
+
* @returns {ServiceEndpoint}
|
|
353
|
+
*/
|
|
354
|
+
export function delegatedClientsServiceEntry({ accountDid, companionDid }) {
|
|
355
|
+
return {
|
|
356
|
+
id: `${accountDid}#${DELEGATED_CLIENTS_SERVICE_FRAGMENT}`,
|
|
357
|
+
type: DELEGATED_CLIENTS_SERVICE_TYPE,
|
|
358
|
+
serviceEndpoint: companionDid
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The companion DID the account document currently points at: the
|
|
363
|
+
* `serviceEndpoint` of the service entry whose `type` names (or includes)
|
|
364
|
+
* {@link DELEGATED_CLIENTS_SERVICE_TYPE}. Only a bare DID-string endpoint
|
|
365
|
+
* counts -- the same predicate the server's inspector clause evaluates, so
|
|
366
|
+
* wallet and server can never disagree on which generation is pointed.
|
|
367
|
+
*
|
|
368
|
+
* @param options {object}
|
|
369
|
+
* @param options.doc {DIDDoc} the resolved (and verified) account document
|
|
370
|
+
* @returns {string | undefined}
|
|
371
|
+
*/
|
|
372
|
+
export function delegatedClientsPointer({ doc }) {
|
|
373
|
+
for (const entry of doc.service ?? []) {
|
|
374
|
+
const types = Array.isArray(entry.type) ? entry.type : [entry.type];
|
|
375
|
+
if (types.includes(DELEGATED_CLIENTS_SERVICE_TYPE) &&
|
|
376
|
+
typeof entry.serviceEndpoint === 'string') {
|
|
377
|
+
return entry.serviceEndpoint;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The unlock-record sibling delegation's `allowedAction` set: GET beside PUT,
|
|
384
|
+
* so an enrolling transient client can read the companion head it appends to.
|
|
385
|
+
* Wire-level and permanent (wallet-core decision 0005): the server's
|
|
386
|
+
* inspector clause admits a delegated-clients delegation with `allowedAction`
|
|
387
|
+
* a subset of exactly this pair.
|
|
388
|
+
*/
|
|
389
|
+
export const DELEGATED_CLIENTS_DELEGATION_ACTIONS = ['GET', 'PUT'];
|
|
390
|
+
/**
|
|
391
|
+
* The sibling delegation's lifetime: the house standing-zcap value (one
|
|
392
|
+
* year; see `standingZcap.ts`). It rots on exactly the account bridge's axis
|
|
393
|
+
* -- same signer, same current-key-set rule, same renewal window -- so the
|
|
394
|
+
* re-mint pass that refreshes the bridge refreshes it too.
|
|
395
|
+
*/
|
|
396
|
+
export const DELEGATED_CLIENTS_DELEGATION_TTL_MS = STANDING_ZCAP_TTL_MS;
|
|
397
|
+
/**
|
|
398
|
+
* Mints one delegated-clients (companion-Space) delegation: the pre-minted
|
|
399
|
+
* zcap sealed into a standing credential's unlock record beside the account
|
|
400
|
+
* bridge, which is what lets a transient login reach the companion log with
|
|
401
|
+
* nothing but the credential. The shape is a permanent wire artifact
|
|
402
|
+
* (wallet-core decision 0005):
|
|
403
|
+
*
|
|
404
|
+
* - `invocationTarget` is the AUXILIARY companion Space's items subtree --
|
|
405
|
+
* the Space URL with a trailing slash, built with was-client's paths
|
|
406
|
+
* helpers so the bytes match the server's target check on a sub-path
|
|
407
|
+
* deployment. Generation coverage comes from segment-bounded attenuation
|
|
408
|
+
* over the flat `gen-` collection names, so no GC cycle rewrites the
|
|
409
|
+
* record or the registry.
|
|
410
|
+
* - `controller` is the credential-derived signing DID (the same grantee
|
|
411
|
+
* the account bridge names).
|
|
412
|
+
* - `allowedActions` is {@link DELEGATED_CLIENTS_DELEGATION_ACTIONS}.
|
|
413
|
+
* - The chain is rooted directly in the auxiliary Space's root zcap.
|
|
414
|
+
* - `expires` is {@link DELEGATED_CLIENTS_DELEGATION_TTL_MS} out.
|
|
415
|
+
*
|
|
416
|
+
* @param options {object}
|
|
417
|
+
* @param options.zcapClient {ZcapClient} the delegating signer (an
|
|
418
|
+
* enrolled client's promoted signer, or the account ladder VM)
|
|
419
|
+
* @param options.wasServerUrl {string} the auxiliary Space's storage
|
|
420
|
+
* server (the account pointer's host)
|
|
421
|
+
* @param options.companionSpaceId {string} the auxiliary companion
|
|
422
|
+
* Space's id
|
|
423
|
+
* @param options.controller {string} the credential-derived signing DID
|
|
424
|
+
* @param [options.now] {number} epoch milliseconds, for tests
|
|
425
|
+
* @returns {Promise<IZcap>}
|
|
426
|
+
*/
|
|
427
|
+
export async function mintDelegatedClientsDelegation({ zcapClient, wasServerUrl, companionSpaceId, controller, now = Date.now() }) {
|
|
428
|
+
const spaceUrl = toUrl({
|
|
429
|
+
serverUrl: wasServerUrl,
|
|
430
|
+
path: spacePath(companionSpaceId)
|
|
431
|
+
});
|
|
432
|
+
return (await zcapClient.delegate({
|
|
433
|
+
capability: rootCapabilityId(spaceUrl),
|
|
434
|
+
invocationTarget: toUrl({
|
|
435
|
+
serverUrl: wasServerUrl,
|
|
436
|
+
path: spaceItems(companionSpaceId)
|
|
437
|
+
}),
|
|
438
|
+
controller,
|
|
439
|
+
allowedActions: [...DELEGATED_CLIENTS_DELEGATION_ACTIONS],
|
|
440
|
+
expires: new Date(now + DELEGATED_CLIENTS_DELEGATION_TTL_MS)
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The auxiliary companion Space id a delegated-clients delegation targets,
|
|
445
|
+
* read out of its `invocationTarget` (the items-subtree URL,
|
|
446
|
+
* `.../space/<companionSpaceId>/`). The id has no other home -- a transient
|
|
447
|
+
* login learns the Space from the delegation it unwraps, and a refresh pass
|
|
448
|
+
* that holds the old delegation rebuilds the target from it -- so this parse
|
|
449
|
+
* is the one reader. Returns `undefined` on anything that is not an
|
|
450
|
+
* items-subtree Space URL.
|
|
451
|
+
*
|
|
452
|
+
* @param options {object}
|
|
453
|
+
* @param options.delegation {IZcap} a delegated-clients delegation
|
|
454
|
+
* @returns {string | undefined}
|
|
455
|
+
*/
|
|
456
|
+
export function delegatedClientsDelegationSpaceId({ delegation }) {
|
|
457
|
+
const target = delegation.invocationTarget;
|
|
458
|
+
if (typeof target !== 'string') {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
let path;
|
|
462
|
+
try {
|
|
463
|
+
path = new URL(target).pathname;
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
// The items-subtree URL ends "/space/<id>/" -- the load-bearing trailing
|
|
469
|
+
// slash leaves one empty final segment.
|
|
470
|
+
const segments = path.split('/');
|
|
471
|
+
if (segments.length < 4 ||
|
|
472
|
+
segments[segments.length - 1] !== '' ||
|
|
473
|
+
segments[segments.length - 3] !== 'space') {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
const spaceId = segments[segments.length - 2];
|
|
477
|
+
return spaceId ? decodeURIComponent(spaceId) : undefined;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The type IRI of the companion document's generation-delegation service
|
|
481
|
+
* entry -- the generation's standing Space-scoped zcap, embedded where an
|
|
482
|
+
* enrolling transient client can reach it before it holds any other
|
|
483
|
+
* authority. Wire-level and permanent: readers (this module's
|
|
484
|
+
* {@link embeddedGenerationDelegation}, the app-side loader) dispatch on this
|
|
485
|
+
* IRI, never on the entry's fragment id, which is non-semantic by the byoe
|
|
486
|
+
* service-entry convention.
|
|
487
|
+
*/
|
|
488
|
+
export const GENERATION_DELEGATION_SERVICE_TYPE = 'https://w3id.org/byoe#GenerationDelegation';
|
|
489
|
+
/**
|
|
490
|
+
* The fragment id the wallet mints for a fresh generation-delegation service
|
|
491
|
+
* entry. Non-semantic by the byoe service-entry convention -- readers MUST
|
|
492
|
+
* dispatch on {@link GENERATION_DELEGATION_SERVICE_TYPE} -- and stable: a
|
|
493
|
+
* renewal replaces an existing entry's endpoint in place, its id preserved
|
|
494
|
+
* verbatim, whatever it is.
|
|
495
|
+
*/
|
|
496
|
+
const GENERATION_DELEGATION_SERVICE_FRAGMENT = 'generation-delegation';
|
|
497
|
+
/**
|
|
498
|
+
* The generation delegation's `allowedAction` set: the full closed WAS
|
|
499
|
+
* action vocabulary. Wire-level and permanent (the app-connect-spec
|
|
500
|
+
* generation-delegation record): attenuation is structural, not enumerated
|
|
501
|
+
* -- child-within-parent is enforced on both actions and targets, so any
|
|
502
|
+
* verb missing here would cap every transient-visit App Connect grant below
|
|
503
|
+
* its durable-client shape. What stays outside the delegation is carried by
|
|
504
|
+
* the TARGET instead: the items subtree excludes the bare Space URL, and
|
|
505
|
+
* with it the Space Description PUT (a controller rewrite) and the Space
|
|
506
|
+
* DELETE.
|
|
507
|
+
*/
|
|
508
|
+
export const GENERATION_DELEGATION_ACTIONS = [
|
|
509
|
+
'GET',
|
|
510
|
+
'HEAD',
|
|
511
|
+
'POST',
|
|
512
|
+
'PUT',
|
|
513
|
+
'DELETE'
|
|
514
|
+
];
|
|
515
|
+
/**
|
|
516
|
+
* The generation delegation's lifetime: the house standing-zcap value (one
|
|
517
|
+
* year; see `standingZcap.ts`). GC's explicit revoke is the intended
|
|
518
|
+
* end-of-life; expiry is the backstop, deliberately not matched to the
|
|
519
|
+
* quarterly GC cadence -- a 90-day-class TTL would begin renewal churn
|
|
520
|
+
* exactly when GC is merely due.
|
|
521
|
+
*/
|
|
522
|
+
export const GENERATION_DELEGATION_TTL_MS = STANDING_ZCAP_TTL_MS;
|
|
523
|
+
/**
|
|
524
|
+
* Mints one generation delegation: the standing Space-scoped zcap a
|
|
525
|
+
* generation's transient clients invoke under. The shape is a permanent wire
|
|
526
|
+
* artifact (the app-connect-spec generation-delegation record):
|
|
527
|
+
*
|
|
528
|
+
* - `invocationTarget` is the ACCOUNT Space's items subtree -- the Space URL
|
|
529
|
+
* with a trailing slash, built with was-client's paths helpers so the
|
|
530
|
+
* bytes match the server's target check on a sub-path deployment. The
|
|
531
|
+
* bare Space URL sits outside the capability bytes (see
|
|
532
|
+
* {@link GENERATION_DELEGATION_ACTIONS} for what that excludes).
|
|
533
|
+
* - `controller` is the bare companion DID string. Transient keys invoke as
|
|
534
|
+
* `<companionDid>#<vm>`, and the server's inspector clause compares this
|
|
535
|
+
* string against the account document's delegated-clients pointer.
|
|
536
|
+
* - The chain is rooted directly in the account Space's root zcap, so an
|
|
537
|
+
* App Connect grant delegated under it forms the depth-3 chain
|
|
538
|
+
* `[root id string, this delegation embedded]`.
|
|
539
|
+
* - `expires` is {@link GENERATION_DELEGATION_TTL_MS} out.
|
|
540
|
+
*
|
|
541
|
+
* The delegation signer is the caller's choice of licensed authority: the
|
|
542
|
+
* account ladder VM (`ladderVmZcapClient`) or an enrolled durable client's
|
|
543
|
+
* promoted signer (`webvhZcapClient`).
|
|
544
|
+
*
|
|
545
|
+
* @param options {object}
|
|
546
|
+
* @param options.zcapClient {ZcapClient} the delegating signer (ladder VM
|
|
547
|
+
* or a durable client's promoted signer)
|
|
548
|
+
* @param options.wasServerUrl {string} the ACCOUNT Space's storage server
|
|
549
|
+
* @param options.spaceId {string} the ACCOUNT Space's id
|
|
550
|
+
* @param options.companionDid {string} the generation's companion DID
|
|
551
|
+
* @param [options.now] {number} epoch milliseconds, for tests
|
|
552
|
+
* @returns {Promise<IZcap>}
|
|
553
|
+
*/
|
|
554
|
+
export async function mintGenerationDelegation({ zcapClient, wasServerUrl, spaceId, companionDid, now = Date.now() }) {
|
|
555
|
+
companionDidParts({ did: companionDid });
|
|
556
|
+
const spaceUrl = toUrl({ serverUrl: wasServerUrl, path: spacePath(spaceId) });
|
|
557
|
+
return (await zcapClient.delegate({
|
|
558
|
+
capability: rootCapabilityId(spaceUrl),
|
|
559
|
+
invocationTarget: toUrl({
|
|
560
|
+
serverUrl: wasServerUrl,
|
|
561
|
+
path: spaceItems(spaceId)
|
|
562
|
+
}),
|
|
563
|
+
controller: companionDid,
|
|
564
|
+
allowedActions: [...GENERATION_DELEGATION_ACTIONS],
|
|
565
|
+
expires: new Date(now + GENERATION_DELEGATION_TTL_MS)
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* A child grant's `expires` under the generation delegation: the requested
|
|
570
|
+
* TTL, clamped to the delegation's own expiry -- the library's per-hop
|
|
571
|
+
* monotonicity rule IS the TTL clamp, so a grant minted past the parent's
|
|
572
|
+
* `expires` would verify nowhere. By construction the bounded grants (30-day
|
|
573
|
+
* read, 7-day write) always receive their full TTL; only 365-day-class
|
|
574
|
+
* grants ever meet the clamp, at 30 or more days remaining (the
|
|
575
|
+
* renew-precedes-mint stage keeps the delegation outside its renewal window
|
|
576
|
+
* whenever a grant is minted).
|
|
577
|
+
*
|
|
578
|
+
* @param options {object}
|
|
579
|
+
* @param options.ttlMs {number} the grant's requested TTL
|
|
580
|
+
* @param options.delegation {IZcap} the generation delegation
|
|
581
|
+
* @param [options.now] {number} epoch milliseconds, for tests
|
|
582
|
+
* @returns {Date}
|
|
583
|
+
*/
|
|
584
|
+
export function clampGrantExpires({ ttlMs, delegation, now = Date.now() }) {
|
|
585
|
+
const parentExpires = Date.parse(delegation.expires ?? '');
|
|
586
|
+
if (Number.isNaN(parentExpires)) {
|
|
587
|
+
throw new Error('generation delegation: no parseable `expires`; refusing to mint a ' +
|
|
588
|
+
'grant under an unbounded parent.');
|
|
589
|
+
}
|
|
590
|
+
return new Date(Math.min(now + ttlMs, parentExpires));
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Builds a fresh generation-delegation service entry for a companion
|
|
594
|
+
* document. The `serviceEndpoint` is the full delegated-zcap JSON as a
|
|
595
|
+
* single map, byte-identical to what `zcapClient.delegate` produced -- the
|
|
596
|
+
* companion entry proof (JCS canonicalization) then covers it byte for byte,
|
|
597
|
+
* so host tampering with the stored delegation is client-visible.
|
|
598
|
+
*
|
|
599
|
+
* @param options {object}
|
|
600
|
+
* @param options.companionDid {string} the generation's companion DID
|
|
601
|
+
* @param options.delegation {IZcap} the minted generation delegation
|
|
602
|
+
* @returns {ServiceEndpoint}
|
|
603
|
+
*/
|
|
604
|
+
export function generationDelegationServiceEntry({ companionDid, delegation }) {
|
|
605
|
+
return {
|
|
606
|
+
id: `${companionDid}#${GENERATION_DELEGATION_SERVICE_FRAGMENT}`,
|
|
607
|
+
type: GENERATION_DELEGATION_SERVICE_TYPE,
|
|
608
|
+
serviceEndpoint: delegation
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The generation delegation a companion document carries: the
|
|
613
|
+
* `serviceEndpoint` map of the service entry whose `type` names (or
|
|
614
|
+
* includes) {@link GENERATION_DELEGATION_SERVICE_TYPE}. Only a map-form
|
|
615
|
+
* endpoint counts (the delegation is embedded as the zcap JSON itself,
|
|
616
|
+
* never as a URL or an encoded string).
|
|
617
|
+
*
|
|
618
|
+
* @param options {object}
|
|
619
|
+
* @param options.doc {DIDDoc} the resolved (and verified) companion
|
|
620
|
+
* document
|
|
621
|
+
* @returns {IZcap | undefined}
|
|
622
|
+
*/
|
|
623
|
+
export function embeddedGenerationDelegation({ doc }) {
|
|
624
|
+
for (const entry of doc.service ?? []) {
|
|
625
|
+
const types = Array.isArray(entry.type) ? entry.type : [entry.type];
|
|
626
|
+
if (types.includes(GENERATION_DELEGATION_SERVICE_TYPE)) {
|
|
627
|
+
const endpoint = entry.serviceEndpoint;
|
|
628
|
+
if (endpoint !== null &&
|
|
629
|
+
typeof endpoint === 'object' &&
|
|
630
|
+
!Array.isArray(endpoint)) {
|
|
631
|
+
return endpoint;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* The companion document's service list with the generation delegation
|
|
639
|
+
* installed: an existing entry's endpoint is replaced in place, its fragment
|
|
640
|
+
* id preserved verbatim (the id is non-semantic and stable); absent one, a
|
|
641
|
+
* fresh entry is appended. Every other service entry is preserved untouched.
|
|
642
|
+
*
|
|
643
|
+
* @param options {object}
|
|
644
|
+
* @param options.doc {DIDDoc} the companion document as published
|
|
645
|
+
* @param options.companionDid {string}
|
|
646
|
+
* @param options.delegation {IZcap}
|
|
647
|
+
* @returns {ServiceEndpoint[]}
|
|
648
|
+
*/
|
|
649
|
+
function withGenerationDelegationEntry({ doc, companionDid, delegation }) {
|
|
650
|
+
const existing = (doc.service ?? []);
|
|
651
|
+
const isDelegationEntry = (entry) => {
|
|
652
|
+
const types = Array.isArray(entry.type) ? entry.type : [entry.type];
|
|
653
|
+
return types.includes(GENERATION_DELEGATION_SERVICE_TYPE);
|
|
654
|
+
};
|
|
655
|
+
return existing.some(isDelegationEntry)
|
|
656
|
+
? existing.map(entry => isDelegationEntry(entry)
|
|
657
|
+
? {
|
|
658
|
+
...entry,
|
|
659
|
+
serviceEndpoint: delegation
|
|
660
|
+
}
|
|
661
|
+
: entry)
|
|
662
|
+
: [
|
|
663
|
+
...existing,
|
|
664
|
+
generationDelegationServiceEntry({ companionDid, delegation })
|
|
665
|
+
];
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Parses the auxiliary Space id and generation segment out of a companion DID
|
|
669
|
+
* string. Both are permanent substrings of every companion DID by
|
|
670
|
+
* construction (`did:webvh:<scid>:<host>:...:space:<spaceId>:<segment>`), and
|
|
671
|
+
* the segment is the generation-identifying half of the companion rung HKDF
|
|
672
|
+
* labels, so this parse is what lets an enrollee derive its writing key from
|
|
673
|
+
* the pointer alone -- no log read, no registry.
|
|
674
|
+
*
|
|
675
|
+
* @param options {object}
|
|
676
|
+
* @param options.did {string} a companion did:webvh string
|
|
677
|
+
* @returns {{ spaceId: string, segment: string }}
|
|
678
|
+
*/
|
|
679
|
+
export function companionDidParts({ did }) {
|
|
680
|
+
const parts = did.split(':');
|
|
681
|
+
const segment = parts[parts.length - 1];
|
|
682
|
+
const spaceId = parts[parts.length - 2];
|
|
683
|
+
if (parts.length < 7 ||
|
|
684
|
+
parts[0] !== 'did' ||
|
|
685
|
+
parts[1] !== 'webvh' ||
|
|
686
|
+
parts[parts.length - 3] !== 'space' ||
|
|
687
|
+
segment === undefined ||
|
|
688
|
+
spaceId === undefined ||
|
|
689
|
+
spaceId.length === 0) {
|
|
690
|
+
throw new Error(`Not a companion did:webvh: "${did}".`);
|
|
691
|
+
}
|
|
692
|
+
assertGenerationSegment(segment);
|
|
693
|
+
return { spaceId, segment };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Thrown when the published companion log commits neither the writing
|
|
697
|
+
* credential's rung-0 key nor its hash -- the mid-generation lockout: a
|
|
698
|
+
* credential bound after the generation's genesis cannot write the companion
|
|
699
|
+
* until an existing writer commits its rung-0 hash or the next GC swap's
|
|
700
|
+
* genesis does. Typed so callers can map it to the fresh-generation path
|
|
701
|
+
* where one is licensed (the transient-recovery continuation) or to honest
|
|
702
|
+
* copy where none is.
|
|
703
|
+
*/
|
|
704
|
+
export class CompanionRungUncommittedError extends Error {
|
|
705
|
+
constructor(message) {
|
|
706
|
+
super(message);
|
|
707
|
+
this.name = 'CompanionRungUncommittedError';
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Reads and resolves the published companion log through the narrow seam, or
|
|
712
|
+
* throws when the generation's `did.jsonl` is missing (an unpointed or
|
|
713
|
+
* deleted generation -- nothing to enroll into).
|
|
714
|
+
*
|
|
715
|
+
* @param options {object}
|
|
716
|
+
* @param options.store {CompanionWriteStore}
|
|
717
|
+
* @param [options.expectedDid] {string}
|
|
718
|
+
* @param [options.pinStore] {ResourceLogPinStore}
|
|
719
|
+
* @param [options.logId] {string}
|
|
720
|
+
* @returns {Promise<PublishedWebvhLog>}
|
|
721
|
+
*/
|
|
722
|
+
async function readCompanionLogOrThrow({ store, expectedDid, pinStore, logId }) {
|
|
723
|
+
// readPublishedLog only calls getIdResourceRaw, so the narrow seam is safe.
|
|
724
|
+
const published = await readPublishedLog({
|
|
725
|
+
idStore: store,
|
|
726
|
+
...(expectedDid !== undefined ? { expectedDid } : {}),
|
|
727
|
+
...(pinStore !== undefined ? { pinStore } : {}),
|
|
728
|
+
...(logId !== undefined ? { logId } : {})
|
|
729
|
+
});
|
|
730
|
+
if (!published) {
|
|
731
|
+
throw new Error('companion: did.jsonl is missing; the generation was never minted or ' +
|
|
732
|
+
'has been collected.');
|
|
733
|
+
}
|
|
734
|
+
return published;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* TRANSIENT ENROLLMENT: publishes one per-visit verification method into a
|
|
738
|
+
* companion generation's log -- one atomic entry, signed by the writing
|
|
739
|
+
* credential's static rung 0 (derived from the ladder seed and the segment;
|
|
740
|
+
* see `companionRung`). The entry:
|
|
741
|
+
*
|
|
742
|
+
* - reveals the writer's rung-0 key into `updateKeys` at its first companion
|
|
743
|
+
* write (later writes re-state it unchanged);
|
|
744
|
+
* - re-states `nextKeyHashes` verbatim -- every standing credential's rung-0
|
|
745
|
+
* hash, the writer's own carry-over hash included -- explicitly on the
|
|
746
|
+
* entry, never inherited from the prior entry's parameters;
|
|
747
|
+
* - adds the transient VM under `capabilityInvocation` ONLY, with all five
|
|
748
|
+
* relationship arrays stated explicitly (no `authentication`, no
|
|
749
|
+
* `assertionMethod`, no `keyAgreement` twin -- the DIDAuth path signs as
|
|
750
|
+
* the bare did:key, and the controller-marker convention does not arise in
|
|
751
|
+
* the companion at all).
|
|
752
|
+
*
|
|
753
|
+
* The transient key set carries no update key, and nothing here touches the
|
|
754
|
+
* ACCOUNT log's `updateKeys` or `nextKeyHashes`. There is no two-entry
|
|
755
|
+
* reveal/add split and no attribution scan: a CAS loser re-signs with the
|
|
756
|
+
* SAME key via the ordinary conflict retry, and resumability reduces to the
|
|
757
|
+
* published document's own state -- a VM already present is a no-op.
|
|
758
|
+
*
|
|
759
|
+
* A writer whose rung-0 key is neither revealed nor committed is refused
|
|
760
|
+
* ({@link CompanionRungUncommittedError}): companion entries verify against
|
|
761
|
+
* the log's own hash-commitment chain, so no admission rule can make an
|
|
762
|
+
* uncommitted key verify mid-log.
|
|
763
|
+
*
|
|
764
|
+
* @param options {object}
|
|
765
|
+
* @param options.store {CompanionWriteStore} the generation's log store
|
|
766
|
+
* (delegated through the credential's sibling delegation, or
|
|
767
|
+
* controller-tier)
|
|
768
|
+
* @param options.ladderSeed {Uint8Array} the credential's ladder seed, from
|
|
769
|
+
* its unlock record
|
|
770
|
+
* @param options.segment {string} the generation collection's name
|
|
771
|
+
* @param options.transientKeyMultibase {string} the visit's in-memory
|
|
772
|
+
* Ed25519 signing key, public multibase
|
|
773
|
+
* @param [options.services] {ServiceEndpoint[]} the companion document's
|
|
774
|
+
* full service-entry list, replacing the published one wholesale; omitted,
|
|
775
|
+
* the prior entries are preserved verbatim (or extended by
|
|
776
|
+
* `mintGenerationDelegation` below). Supplying both is refused in favor of
|
|
777
|
+
* the explicit list
|
|
778
|
+
* @param [options.mintGenerationDelegation] {Function}
|
|
779
|
+
* `({ companionDid }) => Promise<IZcap>` -- mints the generation
|
|
780
|
+
* delegation this entry installs when it publishes the generation's FIRST
|
|
781
|
+
* transient verification method (and the document carries no delegation
|
|
782
|
+
* entry yet). Never invoked otherwise: the delegation is installed with
|
|
783
|
+
* the first transient VM or by the GC ceremony's own install stage, never
|
|
784
|
+
* by genesis (a genesis-embedded signed zcap can never verify -- its
|
|
785
|
+
* `controller` embeds the SCID the genesis hash derives from)
|
|
786
|
+
* @param [options.expectedDid] {string} the companion DID the log must
|
|
787
|
+
* resolve to, from the account document's pointer
|
|
788
|
+
* @param [options.pinStore] {ResourceLogPinStore} chain-head pins (a
|
|
789
|
+
* transient session passes an in-memory store)
|
|
790
|
+
* @param [options.logId] {string} the generation's pin-slot key, from
|
|
791
|
+
* {@link companionLogPinId}; required whenever a `pinStore` is supplied
|
|
792
|
+
* @returns {Promise<{ did: string, doc: DIDDoc, log: DIDLog }>}
|
|
793
|
+
*/
|
|
794
|
+
export async function enrollCompanionTransientClient(options) {
|
|
795
|
+
return withLogConflictRetry(() => enrollCompanionTransientClientOnce(options));
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* One attempt of {@link enrollCompanionTransientClient}, re-invoked by the
|
|
799
|
+
* conflict retry (with the same signing key -- static rung 0 has no
|
|
800
|
+
* advanced-rung retry shape).
|
|
801
|
+
*
|
|
802
|
+
* @param options {object} see {@link enrollCompanionTransientClient}
|
|
803
|
+
* @returns {Promise<{ did: string, doc: DIDDoc, log: DIDLog }>}
|
|
804
|
+
*/
|
|
805
|
+
async function enrollCompanionTransientClientOnce({ store, ladderSeed, segment, transientKeyMultibase, services, mintGenerationDelegation: mintDelegation, expectedDid, pinStore, logId }) {
|
|
806
|
+
assertGenerationSegment(segment);
|
|
807
|
+
const published = await readCompanionLogOrThrow({
|
|
808
|
+
store,
|
|
809
|
+
...(expectedDid !== undefined ? { expectedDid } : {}),
|
|
810
|
+
...(pinStore !== undefined ? { pinStore } : {}),
|
|
811
|
+
...(logId !== undefined ? { logId } : {})
|
|
812
|
+
});
|
|
813
|
+
const { did, doc } = published;
|
|
814
|
+
const vmId = `${did}#${transientKeyMultibase}`;
|
|
815
|
+
// Already enrolled (a completed earlier run): the VM and everything the
|
|
816
|
+
// atomic entry carries beside it landed together, so presence alone is the
|
|
817
|
+
// completion predicate.
|
|
818
|
+
const existingMethods = (doc.verificationMethod ?? []);
|
|
819
|
+
if (existingMethods.some(method => method.id === vmId)) {
|
|
820
|
+
return { did, doc, log: published.log };
|
|
821
|
+
}
|
|
822
|
+
const rung = await companionRung({ ladderSeed, segment });
|
|
823
|
+
const rungHash = await deriveNextKeyHash(rung.keyMultibase);
|
|
824
|
+
const revealed = published.updateKeys.includes(rung.keyMultibase);
|
|
825
|
+
if (!revealed && !published.nextKeyHashes.includes(rungHash)) {
|
|
826
|
+
throw new CompanionRungUncommittedError("companion: the log commits neither this credential's rung-0 key nor " +
|
|
827
|
+
'its hash; a credential bound mid-generation cannot write the ' +
|
|
828
|
+
'companion until a writer commits its hash or the next GC swap does.');
|
|
829
|
+
}
|
|
830
|
+
// A non-rotating entry re-states `updateKeys`, which the resolver checks
|
|
831
|
+
// against the previous entry's commitments -- genesis enforces the
|
|
832
|
+
// carry-over convention, and this refuses a log that lost it anyway.
|
|
833
|
+
await assertCarryOverCommitments({ published });
|
|
834
|
+
// The generation delegation installs with the first transient VM (never at
|
|
835
|
+
// genesis): a delegation-less document about to receive its first method
|
|
836
|
+
// gets the entry minted and appended here, so a generation with no visits
|
|
837
|
+
// never carries a delegation and stays authorization-inert.
|
|
838
|
+
if (services === undefined &&
|
|
839
|
+
mintDelegation !== undefined &&
|
|
840
|
+
existingMethods.length === 0 &&
|
|
841
|
+
embeddedGenerationDelegation({ doc }) === undefined) {
|
|
842
|
+
const delegation = await mintDelegation({ companionDid: did });
|
|
843
|
+
services = withGenerationDelegationEntry({
|
|
844
|
+
doc,
|
|
845
|
+
companionDid: did,
|
|
846
|
+
delegation
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const transientMethod = {
|
|
850
|
+
id: vmId,
|
|
851
|
+
type: MULTIKEY_VM_TYPE,
|
|
852
|
+
controller: did,
|
|
853
|
+
publicKeyMultibase: transientKeyMultibase
|
|
854
|
+
};
|
|
855
|
+
const signer = await updateKeySigner({ seed: rung.seed });
|
|
856
|
+
const updated = await updateDID({
|
|
857
|
+
log: published.log,
|
|
858
|
+
signer,
|
|
859
|
+
additionalContext: [MULTIKEY_CONTEXT_URL],
|
|
860
|
+
updateKeys: [...new Set([...published.updateKeys, rung.keyMultibase])],
|
|
861
|
+
// Re-stated verbatim, the writer's carry-over hash among them; the
|
|
862
|
+
// explicit re-statement (never parameter inheritance) is what lets the
|
|
863
|
+
// NEXT entry's re-stated `updateKeys` resolve.
|
|
864
|
+
nextKeyHashes: [...published.nextKeyHashes],
|
|
865
|
+
verificationMethods: [...existingMethods, transientMethod],
|
|
866
|
+
// All five relationship arrays stated explicitly: the library defaults a
|
|
867
|
+
// purpose-less method into `authentication` at normalization, and the
|
|
868
|
+
// explicit arrays are what override that -- the transient VM appears
|
|
869
|
+
// under `capabilityInvocation` and nowhere else.
|
|
870
|
+
authentication: relationIds(doc.authentication),
|
|
871
|
+
assertionMethod: relationIds(doc.assertionMethod),
|
|
872
|
+
keyAgreement: relationIds(doc.keyAgreement),
|
|
873
|
+
capabilityInvocation: [
|
|
874
|
+
...new Set([...relationIds(doc.capabilityInvocation), vmId])
|
|
875
|
+
],
|
|
876
|
+
capabilityDelegation: relationIds(doc.capabilityDelegation),
|
|
877
|
+
...(services !== undefined ? { services } : {})
|
|
878
|
+
});
|
|
879
|
+
// The log only -- a companion has no did:web projection -- conditional on
|
|
880
|
+
// the read this entry was built on.
|
|
881
|
+
await putLogResource({ store, log: updated.log, ifMatch: published.etag });
|
|
882
|
+
return { did: updated.did, doc: updated.doc, log: updated.log };
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Points the account document's delegated-clients service entry at a
|
|
886
|
+
* companion DID -- the first install after a generation's genesis, and the GC
|
|
887
|
+
* swap's re-point alike. One ordinary document-update entry, signed by an
|
|
888
|
+
* enrolled durable client's active update key; the companion log always
|
|
889
|
+
* publishes FIRST (see {@link mintCompanionGeneration}), so a tear leaves an
|
|
890
|
+
* unpointed, authorization-inert generation, never a dangling pointer.
|
|
891
|
+
*
|
|
892
|
+
* An existing delegated-clients entry is re-pointed in place, its fragment id
|
|
893
|
+
* preserved verbatim (the id is non-semantic and stable); absent one, a fresh
|
|
894
|
+
* entry is appended ({@link delegatedClientsServiceEntry}). Every other
|
|
895
|
+
* service entry, the verification methods, and the relationship arrays are
|
|
896
|
+
* preserved untouched. Idempotent: a document already pointing at the DID is
|
|
897
|
+
* a no-op on the log (it still heals a lagging `did.json`).
|
|
898
|
+
*
|
|
899
|
+
* @param options {object}
|
|
900
|
+
* @param options.idStore {WebvhIdStore} the ACCOUNT log's store
|
|
901
|
+
* @param options.updateKeys {ClientWebvhUpdateKeys} this durable client's
|
|
902
|
+
* update-key seeds
|
|
903
|
+
* @param options.companionDid {string} the generation to point at
|
|
904
|
+
* @param [options.expectedDid] {string} the account DID the log must
|
|
905
|
+
* resolve to, from the account pointer
|
|
906
|
+
* @param [options.pinStore] {ResourceLogPinStore} this client's chain-head
|
|
907
|
+
* pins for the account log
|
|
908
|
+
* @param [options.logId] {string} the account log's pin-slot key, from
|
|
909
|
+
* `accountLogPinId({ spaceId })`; required whenever a `pinStore` is
|
|
910
|
+
* supplied
|
|
911
|
+
* @returns {Promise<{ did: string, doc: DIDDoc }>}
|
|
912
|
+
*/
|
|
913
|
+
export async function setDelegatedClientsPointer(options) {
|
|
914
|
+
return withLogConflictRetry(() => setDelegatedClientsPointerOnce(options));
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* One attempt of {@link setDelegatedClientsPointer}, re-invoked by the
|
|
918
|
+
* conflict retry.
|
|
919
|
+
*
|
|
920
|
+
* @param options {object} see {@link setDelegatedClientsPointer}
|
|
921
|
+
* @returns {Promise<{ did: string, doc: DIDDoc }>}
|
|
922
|
+
*/
|
|
923
|
+
async function setDelegatedClientsPointerOnce({ idStore, updateKeys, companionDid, expectedDid, pinStore, logId }) {
|
|
924
|
+
// Refuses a malformed target before anything is read or written.
|
|
925
|
+
companionDidParts({ did: companionDid });
|
|
926
|
+
const published = await readPublishedLog({
|
|
927
|
+
idStore,
|
|
928
|
+
...(expectedDid !== undefined ? { expectedDid } : {}),
|
|
929
|
+
...(pinStore !== undefined ? { pinStore } : {}),
|
|
930
|
+
...(logId !== undefined ? { logId } : {})
|
|
931
|
+
});
|
|
932
|
+
if (!published) {
|
|
933
|
+
throw new Error('did:webvh: did.jsonl is missing; nothing to point at a companion.');
|
|
934
|
+
}
|
|
935
|
+
const { did, doc } = published;
|
|
936
|
+
if (delegatedClientsPointer({ doc }) === companionDid) {
|
|
937
|
+
await concludeWithPublishedLog({ idStore, published });
|
|
938
|
+
return { did, doc };
|
|
939
|
+
}
|
|
940
|
+
// The entry is signed by this client's active update key; a log that does
|
|
941
|
+
// not authorize it (a rotation torn elsewhere) must heal first.
|
|
942
|
+
const activeKey = await updateKeyMultibase({ seed: updateKeys.updateSeed });
|
|
943
|
+
if (!published.updateKeys.includes(activeKey)) {
|
|
944
|
+
throw new Error("did:webvh: the published log does not authorize this client's active " +
|
|
945
|
+
'update key; finalize the pending rotation before re-pointing the ' +
|
|
946
|
+
'delegated-clients entry.');
|
|
947
|
+
}
|
|
948
|
+
await assertCarryOverCommitments({ published });
|
|
949
|
+
const existing = (doc.service ?? []);
|
|
950
|
+
const isPointerEntry = (entry) => {
|
|
951
|
+
const types = Array.isArray(entry.type) ? entry.type : [entry.type];
|
|
952
|
+
return types.includes(DELEGATED_CLIENTS_SERVICE_TYPE);
|
|
953
|
+
};
|
|
954
|
+
const services = existing.some(isPointerEntry)
|
|
955
|
+
? existing.map(entry => isPointerEntry(entry)
|
|
956
|
+
? { ...entry, serviceEndpoint: companionDid }
|
|
957
|
+
: entry)
|
|
958
|
+
: [
|
|
959
|
+
...existing,
|
|
960
|
+
delegatedClientsServiceEntry({ accountDid: did, companionDid })
|
|
961
|
+
];
|
|
962
|
+
const signer = await updateKeySigner({ seed: updateKeys.updateSeed });
|
|
963
|
+
const updated = await updateDID({
|
|
964
|
+
log: published.log,
|
|
965
|
+
signer,
|
|
966
|
+
alsoKnownAsWeb: true,
|
|
967
|
+
// Re-stated unchanged (the library requires them explicitly while
|
|
968
|
+
// prerotation is active); the carry-over commitments are what make the
|
|
969
|
+
// re-statement resolvable.
|
|
970
|
+
updateKeys: published.updateKeys,
|
|
971
|
+
nextKeyHashes: published.nextKeyHashes,
|
|
972
|
+
services
|
|
973
|
+
});
|
|
974
|
+
await publishUpdatedLog({ idStore, updated, ifMatch: published.etag });
|
|
975
|
+
return { did: updated.did, doc: updated.doc };
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* The whole transient-enrollment ceremony as the enrollee runs it: resolve
|
|
979
|
+
* the account document's delegated-clients pointer, enroll the visit's key
|
|
980
|
+
* into the pointed generation, then RE-READ the pointer -- the GC-race
|
|
981
|
+
* closure. An enrollment landing between a GC pass's guard check and its
|
|
982
|
+
* re-point would otherwise yield a session whose generation the pointer then
|
|
983
|
+
* abandons and whose delegation is already revoked; the enrollee closes the
|
|
984
|
+
* race with one extra read, re-enrolling into the fresh generation on a
|
|
985
|
+
* mismatch. Convergent under retry (each round enrolls into whatever the
|
|
986
|
+
* pointer names NOW), and idempotent per generation like the entry itself.
|
|
987
|
+
*
|
|
988
|
+
* @param options {object}
|
|
989
|
+
* @param options.readAccountDocument {Function} reads the VERIFIED account
|
|
990
|
+
* document (the caller's `verifyAccountLog` read, pins and `expectedDid`
|
|
991
|
+
* applied there); called once per round
|
|
992
|
+
* @param options.storeForSegment {Function} builds the generation's log
|
|
993
|
+
* store for a segment (the delegated store over the credential's sibling
|
|
994
|
+
* delegation, or a controller-tier store)
|
|
995
|
+
* @param options.ladderSeed {Uint8Array} the credential's ladder seed
|
|
996
|
+
* @param options.transientKeyMultibase {string} the visit's in-memory
|
|
997
|
+
* signing key, public multibase
|
|
998
|
+
* @param [options.mintGenerationDelegation] {Function}
|
|
999
|
+
* `({ companionDid }) => Promise<IZcap>` -- forwarded to the enrollment
|
|
1000
|
+
* entry, which installs the minted delegation when it publishes the
|
|
1001
|
+
* generation's first transient VM (see
|
|
1002
|
+
* {@link enrollCompanionTransientClient}). The closure receives whichever
|
|
1003
|
+
* companion DID the round enrolls into, so a GC-race re-enroll mints for
|
|
1004
|
+
* the fresh generation
|
|
1005
|
+
* @param [options.pinStore] {ResourceLogPinStore} chain-head pins for the
|
|
1006
|
+
* generation logs (a transient session passes an in-memory store); slot
|
|
1007
|
+
* keys are derived per generation with {@link companionLogPinId}
|
|
1008
|
+
* @param [options.maxRounds] {number} how many pointer moves to chase
|
|
1009
|
+
* before giving up (a GC pass is quarterly, so more than one mid-ceremony
|
|
1010
|
+
* move means something else is wrong)
|
|
1011
|
+
* @returns {Promise<{ companionDid: string, doc: DIDDoc, log: DIDLog }>}
|
|
1012
|
+
*/
|
|
1013
|
+
export async function enrollTransientClient({ readAccountDocument, storeForSegment, ladderSeed, transientKeyMultibase, mintGenerationDelegation: mintDelegation, pinStore, maxRounds = 3 }) {
|
|
1014
|
+
let accountDoc = await readAccountDocument();
|
|
1015
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
1016
|
+
const companionDid = delegatedClientsPointer({ doc: accountDoc });
|
|
1017
|
+
if (companionDid === undefined) {
|
|
1018
|
+
throw new Error('companion: the account document carries no delegated-clients ' +
|
|
1019
|
+
'service entry; no generation exists to enroll into.');
|
|
1020
|
+
}
|
|
1021
|
+
const { spaceId, segment } = companionDidParts({ did: companionDid });
|
|
1022
|
+
const enrolled = await enrollCompanionTransientClient({
|
|
1023
|
+
store: storeForSegment(segment),
|
|
1024
|
+
ladderSeed,
|
|
1025
|
+
segment,
|
|
1026
|
+
transientKeyMultibase,
|
|
1027
|
+
expectedDid: companionDid,
|
|
1028
|
+
...(mintDelegation !== undefined
|
|
1029
|
+
? { mintGenerationDelegation: mintDelegation }
|
|
1030
|
+
: {}),
|
|
1031
|
+
...(pinStore !== undefined
|
|
1032
|
+
? { pinStore, logId: companionLogPinId({ spaceId, segment }) }
|
|
1033
|
+
: {})
|
|
1034
|
+
});
|
|
1035
|
+
// The GC-race re-read: an unchanged pointer means the enrollment stands
|
|
1036
|
+
// in the pointed generation; a moved one means a concurrent GC abandoned
|
|
1037
|
+
// it, and the next round enrolls into the fresh generation.
|
|
1038
|
+
accountDoc = await readAccountDocument();
|
|
1039
|
+
if (delegatedClientsPointer({ doc: accountDoc }) === companionDid) {
|
|
1040
|
+
return { companionDid, doc: enrolled.doc, log: enrolled.log };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
throw new Error('companion: the delegated-clients pointer kept moving across ' +
|
|
1044
|
+
`${String(maxRounds)} enrollment rounds; giving up.`);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* RENEW PRECEDES MINT: the blocking pre-mint stage a transient App Connect
|
|
1048
|
+
* approval runs before delegating any grant. Reads the companion document
|
|
1049
|
+
* and hands back its embedded generation delegation -- renewing it first
|
|
1050
|
+
* when it is expired or inside the 30-day renewal window ({@link
|
|
1051
|
+
* zcapExpiring}): a fresh delegation is minted through the caller's closure
|
|
1052
|
+
* (ladder-signed -- the renewal must not depend on the very delegation it
|
|
1053
|
+
* replaces; published through the store, which in a transient session is
|
|
1054
|
+
* the credential's sibling delegation, so even a hard-expired delegation is
|
|
1055
|
+
* recoverable), and one companion entry replaces the service entry's
|
|
1056
|
+
* endpoint in place, signed by the writing credential's static rung 0.
|
|
1057
|
+
*
|
|
1058
|
+
* A companion document carrying no delegation entry at all installs one the
|
|
1059
|
+
* same way (the GC ceremony's own install stage and the first-VM install
|
|
1060
|
+
* make this rare; a heal, not a policy).
|
|
1061
|
+
*
|
|
1062
|
+
* Failure is the caller's failure: a renewal that cannot complete throws,
|
|
1063
|
+
* and the App Connect approval fails with the standard retryable-ceremony
|
|
1064
|
+
* posture -- deliberately no clamp-on-failure fallback, which would deliver
|
|
1065
|
+
* exactly the silently short grant this stage exists to prevent. By
|
|
1066
|
+
* construction a grant minted behind a completed renewal never meets the
|
|
1067
|
+
* monotonicity clamp below its full TTL except for 365-day-class grants at
|
|
1068
|
+
* 30 or more days remaining ({@link clampGrantExpires}).
|
|
1069
|
+
*
|
|
1070
|
+
* @param options {object}
|
|
1071
|
+
* @param options.store {CompanionWriteStore} the generation's log store
|
|
1072
|
+
* (delegated through the credential's sibling delegation, or
|
|
1073
|
+
* controller-tier)
|
|
1074
|
+
* @param options.ladderSeed {Uint8Array} the credential's ladder seed, from
|
|
1075
|
+
* its unlock record
|
|
1076
|
+
* @param options.segment {string} the generation collection's name
|
|
1077
|
+
* @param options.mintGenerationDelegation {Function}
|
|
1078
|
+
* `({ companionDid }) => Promise<IZcap>` -- mints the replacement
|
|
1079
|
+
* delegation (ladder-signed in a transient session)
|
|
1080
|
+
* @param [options.expectedDid] {string} the companion DID the log must
|
|
1081
|
+
* resolve to, from the account document's pointer
|
|
1082
|
+
* @param [options.pinStore] {ResourceLogPinStore} chain-head pins (a
|
|
1083
|
+
* transient session passes an in-memory store)
|
|
1084
|
+
* @param [options.logId] {string} the generation's pin-slot key, from
|
|
1085
|
+
* {@link companionLogPinId}; required whenever a `pinStore` is supplied
|
|
1086
|
+
* @param [options.now] {number} epoch milliseconds, for tests
|
|
1087
|
+
* @returns {Promise<{ delegation: IZcap, renewed: boolean }>}
|
|
1088
|
+
*/
|
|
1089
|
+
export async function ensureGenerationDelegationCurrent(options) {
|
|
1090
|
+
return withLogConflictRetry(() => ensureGenerationDelegationCurrentOnce(options));
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* One attempt of {@link ensureGenerationDelegationCurrent}, re-invoked by the
|
|
1094
|
+
* conflict retry (with the same signing key -- static rung 0 has no
|
|
1095
|
+
* advanced-rung retry shape).
|
|
1096
|
+
*
|
|
1097
|
+
* @param options {object} see {@link ensureGenerationDelegationCurrent}
|
|
1098
|
+
* @returns {Promise<{ delegation: IZcap, renewed: boolean }>}
|
|
1099
|
+
*/
|
|
1100
|
+
async function ensureGenerationDelegationCurrentOnce({ store, ladderSeed, segment, mintGenerationDelegation: mintDelegation, expectedDid, pinStore, logId, now }) {
|
|
1101
|
+
assertGenerationSegment(segment);
|
|
1102
|
+
const published = await readCompanionLogOrThrow({
|
|
1103
|
+
store,
|
|
1104
|
+
...(expectedDid !== undefined ? { expectedDid } : {}),
|
|
1105
|
+
...(pinStore !== undefined ? { pinStore } : {}),
|
|
1106
|
+
...(logId !== undefined ? { logId } : {})
|
|
1107
|
+
});
|
|
1108
|
+
const { did, doc } = published;
|
|
1109
|
+
const standing = embeddedGenerationDelegation({ doc });
|
|
1110
|
+
if (standing !== undefined &&
|
|
1111
|
+
!zcapExpiring({
|
|
1112
|
+
...(standing.expires !== undefined
|
|
1113
|
+
? { expires: standing.expires }
|
|
1114
|
+
: {}),
|
|
1115
|
+
...(now !== undefined ? { now } : {})
|
|
1116
|
+
})) {
|
|
1117
|
+
return { delegation: standing, renewed: false };
|
|
1118
|
+
}
|
|
1119
|
+
// The rung refusal precedes the mint: nothing is delegated for a writer
|
|
1120
|
+
// who cannot publish the entry that would carry it.
|
|
1121
|
+
const rung = await companionRung({ ladderSeed, segment });
|
|
1122
|
+
const rungHash = await deriveNextKeyHash(rung.keyMultibase);
|
|
1123
|
+
const revealed = published.updateKeys.includes(rung.keyMultibase);
|
|
1124
|
+
if (!revealed && !published.nextKeyHashes.includes(rungHash)) {
|
|
1125
|
+
throw new CompanionRungUncommittedError("companion: the log commits neither this credential's rung-0 key nor " +
|
|
1126
|
+
'its hash; a credential bound mid-generation cannot renew the ' +
|
|
1127
|
+
'generation delegation until a writer commits its hash or the next ' +
|
|
1128
|
+
'GC swap does.');
|
|
1129
|
+
}
|
|
1130
|
+
await assertCarryOverCommitments({ published });
|
|
1131
|
+
const fresh = await mintDelegation({ companionDid: did });
|
|
1132
|
+
const signer = await updateKeySigner({ seed: rung.seed });
|
|
1133
|
+
const updated = await updateDID({
|
|
1134
|
+
log: published.log,
|
|
1135
|
+
signer,
|
|
1136
|
+
// The writer's rung-0 key reveals at its first companion write, exactly
|
|
1137
|
+
// as the enrollment entry does; `nextKeyHashes` is re-stated verbatim,
|
|
1138
|
+
// never inherited. Verification methods, relationship arrays, and every
|
|
1139
|
+
// other service entry ride the library's prior-state clone untouched.
|
|
1140
|
+
updateKeys: [...new Set([...published.updateKeys, rung.keyMultibase])],
|
|
1141
|
+
nextKeyHashes: [...published.nextKeyHashes],
|
|
1142
|
+
services: withGenerationDelegationEntry({
|
|
1143
|
+
doc,
|
|
1144
|
+
companionDid: did,
|
|
1145
|
+
delegation: fresh
|
|
1146
|
+
})
|
|
1147
|
+
});
|
|
1148
|
+
await putLogResource({ store, log: updated.log, ifMatch: published.etag });
|
|
1149
|
+
return { delegation: fresh, renewed: true };
|
|
1150
|
+
}
|
|
1151
|
+
//# sourceMappingURL=companion.js.map
|