/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * The shared roster-and-cascade tail every account-membership ceremony ends * with: once a ceremony has published its own did:webvh document edit -- * disconnecting an enrolled client, retiring a standing unlock credential -- * what remains is the same two stages, and they are the same code. * * 1. **The user key rotation** in the wrap-set roster, recipients resolved * from the document the edit itself just resolved to (no re-fetch of the * log the ceremony just extended). On a log-governed roster store this * function itself guarantees post-edit anchoring: the controller view built * from the edit's own post-edit log is set as the store's minimum controller * version (`setMinimumControllerVersion`) before anything roster-side * runs, so the rotation and the seal backstop anchor at or past the edit * even where the app's injected controller resolution still serves a * cached pre-edit view. The * roster delivers, never sources, so the stage names no recipient at all: * it converges the roster onto that document (the login sweep's own path), * retiring every current-epoch recipient the document no longer keys in one * rotation. An account with no roster yet stops here: the document edit has * landed, so the membership change IS in force, with nothing to rotate. On * a sealable (log-governed) roster store, the seal backstop follows: a * rotation that no-op'd appended nothing, so the roster log may still be * anchored before the document edit -- `seal()` re-anchors it with an * idempotent no-op entry, best-effort and reported rather than thrown. * 2. **The collection fan-out**: every encrypted collection is re-epoch'd onto * the fresh key in parallel, so writes stop landing under epochs the * removed party can still decrypt. Each log-governed collection store * takes the same post-edit minimum controller version the roster store * took, before its first append, so a collection's own governing log * anchors at or past the edit for the same reasons the roster's does. * Failures are collected per collection and never abort the fan-out. * * Convergence is the design: both stages detect their own completion from * durable state alone -- the roster no-ops once every current-epoch recipient * is document-backed, and a collection is stale exactly when its current * epoch names a non-current key generation -- so a mid-cascade crash strands * nothing permanently and a naive full re-run finishes it (the login-time * completion sweep is the standing backstop). * * The tail has two entry points over the same preamble and fan-out. * `rotateRosterToDocumentAndCascade` is the document-converging one above, * for a ceremony whose document edit has already landed. * `retireRosterRecipientAndCascade` is the recipient-naming one: for a * ceremony that must rotate BEFORE its own document edit -- the two forget * ceremonies, where the forgetting client can sign nothing after its removal * entry -- the document still lists the retiring recipient, so convergence * would retire nothing, and the caller names the roster kid to retire * instead. That entry point reads the fresh key back through a key the * caller names (the standing credential's, whose wrap survives the rotation) * and runs no seal backstop: with the document edit still ahead, there is no * removal to seal against, and on the last-client transition the rotation * itself is the one ladder-signed append its anchor licenses. */ import type { DIDDoc, DIDLog } from '@interop/did-method-webvh'; import type { IKeyAgreementKey } from '@interop/data-integrity-core'; import type { CollectionEncryption } from '@interop/was-client'; import type { EncryptionDescriptorStore } from '@interop/was-client/edv/core'; import { type WebvhResourceLogController } from '../resourceLog/index.js'; import { type UserKeyCascadeResult } from './userKeyCascade.js'; import type { UserKey } from './userKey.js'; /** * What the roster's seal backstop reported: `sealed` (the roster log's head * still anchored before the document edit, and the backstop append landed), * `noop` (already sealed -- the rotation itself was the sealing append, or a * re-run found nothing to do), or `failed` (the seal could not run; carried * in `error`, never thrown -- the ceremony stays a resumable success and the * login sweep re-seals). */ export interface RosterSealReport { outcome: 'sealed' | 'noop' | 'failed'; error?: unknown; } /** * Where the cascade's collection fan-out gets its work: which encrypted * collections exist (only the app knows -- a mobile replica names the * collections it replicates, a web wallet also lists the app-provisioned ones * remotely) and how each one's descriptor store and encryption declaration are * reached. */ export interface CascadeCollections { collectionIds: string[] | (() => Promise); /** * Each collection's descriptor store. A log-governed one * (`collectionDescriptorLogStore`) is anchored at the ceremony's post-edit * controller view before its first append. * * The signer contract, the same one the roster store carries: every write * here is a signed log append, and its proof key must be listed under * `assertionMethod` in the account document AT THE ANCHORED (post-edit) * version. A ceremony that strikes the key its own collection stores sign * with must build them on a key its edit leaves standing, or every append * is refused and the collections stay keyed to the retired generation. */ storeFor: (collectionId: string) => EncryptionDescriptorStore; isEncrypted?: (collectionId: string) => Promise; } /** * What the shared tail reports: whether the roster rotated on this run -- a * re-run of an already-complete ceremony reports `false`; the * document-converging entry point reads it off the adopting read, so a * caller holding no cached key sees `true` on its first read either way, * while the recipient-retiring one reports whether THIS call appended -- the roster's * seal-backstop report (present when the roster store is sealable and the * roster stage ran), the per-collection fan-out result, and the rotated key * with the roster descriptor it was read from. */ export interface RosterCascadeResult { rotated: boolean; rosterSeal?: RosterSealReport; collections: UserKeyCascadeResult; userKey?: UserKey; rosterDescriptor?: CollectionEncryption; } /** * The post-edit anchoring guarantee, shared by both entry points: every * append this tail makes -- the roster's, the seal backstop's removal * detection, and each collection's own governing-log rotation -- must run * under a controller view that includes the log the ceremony is anchoring * at, or it anchors before the edit. On the roster that leaves the log * unsealed with the seal blind to the removal, and on the last-client * transition's ladder-signed rotation it lands before the reinstall version * and the ceremony-tail license refuses it. A collection store resolving a * stale cached view has the same defect one collection down: its rotation * would anchor before the strike and seal nothing, and a ladder-signed * append there would be refused for naming a version the strike is not in. * * Rather than leaving that to the injected stores' own controller wiring, * the view built from the ceremony's log is set as each store's minimum * controller version; a fresher resolved view still wins. A store that is * not log-governed has no controller view to anchor and is left alone. * * This function anchors the ROSTER store and returns the view it built, so * the caller threads the same view into the collection fan-out * ({@link cascadeCollectionsToUserKey}'s `controller`) rather than building a * second one. Exported for the one caller that reads the roster before its * anchoring entry exists (the last-client transition's pre-pair probe, * anchored at the pre-transition head it verified). * * @param options {object} * @param options.rosterStore {EncryptionDescriptorStore} * @param options.did {string} * @param options.log {DIDLog} * @returns {WebvhResourceLogController} the controller view built from the * ceremony's log */ export declare function anchorRosterStoreAt({ rosterStore, did, log }: { rosterStore: EncryptionDescriptorStore; did: string; log: DIDLog; }): WebvhResourceLogController; /** * Persists a rotated key: called with `{ userKey, latestEpochId, descriptor }` * after the roster read and BEFORE the fan-out. The key and the epoch pin * must persist atomically. */ export type UserKeyAdoptedHook = (adopted: { userKey: UserKey; latestEpochId: string; descriptor: CollectionEncryption; }) => Promise; /** * Runs the roster rotation (with its seal backstop) and the collection * fan-out over the document a ceremony's own edit just published. See the * module doc for the order and the convergence story. * * The roster store and every log-governed collection store must sign with a * key `doc` lists under `assertionMethod`: both stages append to a governed * log anchored at this post-edit version, so a signer the edit struck is * refused everywhere it writes. * * @param options {object} * @param options.rosterStore {EncryptionDescriptorStore} the * `key-map/user-key.jsonl` roster store * @param options.did {string} the account's did:webvh * @param options.doc {DIDDoc} the document as the ceremony's edit left it * @param options.log {DIDLog} the post-edit log, which the minimum * controller version is built from * @param [options.userKey] {UserKey} this client's cached user key * @param options.clientKeyAgreementKey {IKeyAgreementKey} this client's own * (identity) key-agreement key -- its roster entry * @param [options.pinnedEpochId] {string} the locally pinned latest-seen * roster epoch * @param [options.onUserKeyAdopted] {UserKeyAdoptedHook} persists a * rotated key * @param options.collections {CascadeCollections} the fan-out's work * @returns {Promise} */ export declare function rotateRosterToDocumentAndCascade({ rosterStore, did, doc, log, userKey, clientKeyAgreementKey, pinnedEpochId, onUserKeyAdopted, collections }: { rosterStore: EncryptionDescriptorStore; did: string; doc: DIDDoc; log: DIDLog; userKey?: UserKey; clientKeyAgreementKey: IKeyAgreementKey; pinnedEpochId?: string | null; onUserKeyAdopted?: UserKeyAdoptedHook; collections: CascadeCollections; }): Promise; /** * The recipient-naming entry point: retires ONE named roster recipient and * runs the collection fan-out, for a ceremony that rotates BEFORE its own * document edit (see the module doc). The document handed in still keys the * retiring recipient, so the rotation names its kid explicitly; the fresh key * is read back through `readBackKeyAgreementKey`, a recipient whose wrap * survives the rotation (the standing credential's, on both forgets). No * seal backstop runs here. * * Every stage detects its own completion from durable state: a recipient the * current epoch no longer wraps to skips the append (so a re-run of a * torn-after-rotation ceremony attempts no second append at the same anchor, * which on a ladder-signed roster the one-shot license would refuse), the * read-back adopts whatever the roster now delivers, and the fan-out is * staleness-driven. An account with no roster yet reports `rotated: false` * with an empty fan-out and no key. * * The signer contract is the other entry point's: the roster store and every * log-governed collection store must sign with a key `doc` lists under * `assertionMethod`. Here that document is the pre-edit one, so the * still-standing client this ceremony is about to remove is a valid signer. * * @param options {object} * @param options.rosterStore {EncryptionDescriptorStore} the * `key-map/user-key.jsonl` roster store * @param options.did {string} the account's did:webvh * @param options.doc {DIDDoc} the document the rotation's recipients are * resolved from -- the one `log` resolves to * @param options.log {DIDLog} the log the rotation anchors at, which the * minimum controller version is built from: the pre-edit head for a plain * forget, the post-reinstall head for the last-client transition * @param options.retireRecipientId {string} the roster kid to retire * @param [options.userKey] {UserKey} this client's cached user key * @param options.readBackKeyAgreementKey {IKeyAgreementKey} the recipient * whose wrap survives the rotation, reading the fresh key back and * unwrapping the generations for the fan-out * @param [options.pinnedEpochId] {string} the locally pinned latest-seen * roster epoch * @param [options.onUserKeyAdopted] {UserKeyAdoptedHook} persists a * rotated key * @param options.collections {CascadeCollections} the fan-out's work * @returns {Promise} `rotated` says whether THIS run * retired the recipient's wrap; `rosterSeal` is never present * @throws {UserKeyRosterIntegrityError} the roster's `currentEpoch` names * no epoch in its own list */ export declare function retireRosterRecipientAndCascade({ rosterStore, did, doc, log, retireRecipientId, userKey, readBackKeyAgreementKey, pinnedEpochId, onUserKeyAdopted, collections }: { rosterStore: EncryptionDescriptorStore; did: string; doc: DIDDoc; log: DIDLog; retireRecipientId: string; userKey?: UserKey; readBackKeyAgreementKey: IKeyAgreementKey; pinnedEpochId?: string | null; onUserKeyAdopted?: UserKeyAdoptedHook; collections: CascadeCollections; }): Promise; //# sourceMappingURL=userKeyRosterCascade.d.ts.map