@kubun/plugin-p2p 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/lib/context/group.js +3 -0
  2. package/lib/context/join.js +3 -0
  3. package/lib/context/peer.js +7 -2
  4. package/lib/context/sync.js +63 -5
  5. package/lib/context/types.d.ts +14 -1
  6. package/lib/context/types.js +12 -0
  7. package/lib/groups/access-default-apply.d.ts +42 -0
  8. package/lib/groups/access-default-apply.js +75 -0
  9. package/lib/groups/broadcast-codec.d.ts +1 -1
  10. package/lib/groups/broadcast-message.d.ts +240 -0
  11. package/lib/groups/broadcast-message.js +1 -0
  12. package/lib/groups/broadcast.d.ts +19 -266
  13. package/lib/groups/broadcast.js +30 -204
  14. package/lib/groups/credential-apply.d.ts +82 -0
  15. package/lib/groups/credential-apply.js +410 -0
  16. package/lib/groups/credential-grant-token.d.ts +42 -0
  17. package/lib/groups/credential-grant-token.js +51 -0
  18. package/lib/groups/credential-grant.d.ts +98 -0
  19. package/lib/groups/credential-grant.js +165 -0
  20. package/lib/groups/group-handlers.js +25 -2
  21. package/lib/groups/group-mls.d.ts +5 -0
  22. package/lib/groups/group-mls.js +4 -1
  23. package/lib/groups/group-peer-manager.d.ts +7 -1
  24. package/lib/groups/group-peer-manager.js +44 -1
  25. package/lib/groups/group-protocols.d.ts +227 -0
  26. package/lib/groups/group-protocols.js +146 -0
  27. package/lib/groups/join-utils.d.ts +5 -0
  28. package/lib/groups/join-utils.js +5 -1
  29. package/lib/groups/ledger-adopt.d.ts +84 -0
  30. package/lib/groups/ledger-adopt.js +142 -0
  31. package/lib/groups/ledger-commit-fold.d.ts +3 -1
  32. package/lib/groups/ledger-commit-fold.js +3 -0
  33. package/lib/groups/ledger-ingest.d.ts +23 -1
  34. package/lib/groups/ledger-ingest.js +30 -1
  35. package/lib/groups/manager.d.ts +5 -0
  36. package/lib/groups/manager.js +6 -1
  37. package/lib/groups/peer-presence.d.ts +9 -2
  38. package/lib/groups/peer-presence.js +14 -2
  39. package/lib/groups/peer-selection.d.ts +9 -0
  40. package/lib/groups/peer-selection.js +10 -0
  41. package/lib/hub/wiring.d.ts +6 -1
  42. package/lib/hub/wiring.js +2 -1
  43. package/lib/index.d.ts +5 -1
  44. package/lib/index.js +58 -6
  45. package/lib/peer/blob-fetch.d.ts +45 -0
  46. package/lib/peer/blob-fetch.js +89 -0
  47. package/lib/peer/blob-handlers.d.ts +11 -0
  48. package/lib/peer/blob-handlers.js +123 -0
  49. package/lib/peer/controller-fetch.d.ts +6 -0
  50. package/lib/peer/controller-fetch.js +59 -0
  51. package/lib/peer/controller-handlers.d.ts +10 -0
  52. package/lib/peer/controller-handlers.js +28 -0
  53. package/lib/protocol.d.ts +279 -0
  54. package/lib/protocol.js +358 -0
  55. package/lib/sync/access-default-sender.d.ts +1 -1
  56. package/lib/sync/broadcast-queue.d.ts +1 -1
  57. package/lib/sync/forwarder.d.ts +1 -1
  58. package/lib/sync/handlers.js +98 -1
  59. package/lib/sync/sync-manager.d.ts +10 -1
  60. package/lib/sync/sync-manager.js +24 -2
  61. package/lib/types.d.ts +18 -0
  62. package/package.json +44 -35
@@ -0,0 +1,165 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { toB64U } from '@sozai/codec';
3
+ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, signCredentialKeyGrant } from './credential-grant-token.js';
4
+ /**
5
+ * The recipient of a grant holds no leaf in the group, so there is no
6
+ * authenticated document to wrap to.
7
+ *
8
+ * Named rather than left to fail at wrap time: an unresolved recipient reaches
9
+ * `deriveSharedSecret` as whatever string the caller passed, and comes back as a
10
+ * DID-shaped complaint about a form — which reads as a bad argument when what
11
+ * happened is that the group has no such member.
12
+ */ export class CredentialRecipientNotInGroup extends Error {
13
+ #groupID;
14
+ #recipientDID;
15
+ constructor(groupID, recipientDID){
16
+ super(`Group ${groupID} has no member ${recipientDID} to grant a credential key to`);
17
+ this.name = 'CredentialRecipientNotInGroup';
18
+ this.#groupID = groupID;
19
+ this.#recipientDID = recipientDID;
20
+ }
21
+ get groupID() {
22
+ return this.#groupID;
23
+ }
24
+ get recipientDID() {
25
+ return this.#recipientDID;
26
+ }
27
+ }
28
+ /**
29
+ * The rows a grant wrote are gone by the time the frame is assembled.
30
+ *
31
+ * Reachable: a rotation landing between the grant's version read and this read
32
+ * back leaves the new wrapping below the current version, so it is not among the
33
+ * ones read here. Refusing is what stops a frame going out that names a version
34
+ * with no way in.
35
+ */ export class CredentialGrantNotReadable extends Error {
36
+ #keyID;
37
+ constructor(keyID, detail){
38
+ super(`Credential key ${keyID} cannot be read back for a grant frame: ${detail}`);
39
+ this.name = 'CredentialGrantNotReadable';
40
+ this.#keyID = keyID;
41
+ }
42
+ get keyID() {
43
+ return this.#keyID;
44
+ }
45
+ }
46
+ /**
47
+ * Grant a credential key to a co-member, resolving what to wrap to from the MLS
48
+ * roster, and broadcast the frame that carries it there.
49
+ *
50
+ * The leaf credential is the authenticated original — signed, and immutable for
51
+ * as long as the leaf exists — so it is the only copy of a member's resolvable
52
+ * DID that cannot have been rewritten. The membership row cannot answer: its
53
+ * `member_did` is normalized, and for `did:peer:4` that is the short form, which
54
+ * carries no document and so no agreement key.
55
+ *
56
+ * The write happens OUTSIDE the handle lock. Resolving is a pure read of the
57
+ * ratchet tree, while the grant is a store call, and holding a group's mutex
58
+ * across one is the deadlock this repo has already paid for once.
59
+ */ export async function grantCredentialKeyToMember(params) {
60
+ const { registry, credentials, identity, store, groupID, keyID, recipientDID, stores } = params;
61
+ const wrappableDID = await registry.readHandle(groupID, (handle)=>handle.findMemberLongForm(recipientDID), {
62
+ stores
63
+ });
64
+ if (wrappableDID == null) {
65
+ throw new CredentialRecipientNotInGroup(groupID, recipientDID);
66
+ }
67
+ // `methods` stays undefined here — a resolved roster long form needs none — and
68
+ // the per-request authority overrides the manager's constructed default.
69
+ const result = await credentials.grant(keyID, wrappableDID, {
70
+ authority: params.authority
71
+ });
72
+ // Read back NOW and dispatch on commit, not the reverse. The read has to run
73
+ // on the caller's connection while the transaction is still open, and it has
74
+ // to see the state the grant left rather than whatever a later write leaves —
75
+ // an entry written after this point belongs to its own `credential:entry-put`.
76
+ const frame = await readGrantFrame(store, identity, keyID, wrappableDID);
77
+ stores.onCommit(()=>params.scheduleBroadcast(groupID, frame));
78
+ return result;
79
+ }
80
+ /**
81
+ * Rebuild the frame from what the grant persisted, rather than from what the
82
+ * manager held mid-call.
83
+ *
84
+ * The stored rows are the only copy that survives the call, and they are what a
85
+ * receiver has to end up with — so assembling from anything else would let the
86
+ * frame and the granter's own store disagree without either being wrong.
87
+ */ async function readGrantFrame(store, identity, keyID, recipientWrappableDID) {
88
+ const bundle = await readCredentialKeyBundle(store, keyID, recipientWrappableDID);
89
+ return {
90
+ type: 'credential:key-grant',
91
+ ...bundle,
92
+ // Digests over the carried bytes, not a re-read: the token has to describe
93
+ // the frame that ships, so anything computed from a second read could
94
+ // authenticate something other than what travels.
95
+ auth: await signCredentialKeyGrant(identity, {
96
+ keyID: bundle.keyID,
97
+ keyVersion: bundle.keyVersion,
98
+ suite: bundle.suite,
99
+ recipientDID: bundle.wrapping.recipientDID,
100
+ wrappingDigest: credentialWrappingDigest(bundle.wrapping),
101
+ entriesDigest: credentialEntriesDigest(bundle.entries),
102
+ keyBranchesDigest: credentialKeyBranchesDigest(bundle.keyBranches)
103
+ })
104
+ };
105
+ }
106
+ /**
107
+ * Read one key's row bytes back for a recipient, as a grant carries them minus
108
+ * the `auth` envelope. Shared by the grant path and the credential reconcile
109
+ * lane, which both have to hand a receiver exactly the granter's stored rows.
110
+ *
111
+ * Throws {@link CredentialGrantNotReadable} on a granter-side gap — a missing
112
+ * key row, or a wrapping a rotation removed between the version read and this
113
+ * read back — so a caller never ships a bundle that names bytes with no way in.
114
+ */ export async function readCredentialKeyBundle(store, keyID, recipientWrappableDID) {
115
+ /**
116
+ * A row with no op is a row nothing vouches for, and shipping it would ask the
117
+ * receiver to invent an author. Granter-side failure, like a missing wrapping.
118
+ */ const readOp = async (kind, subjectID)=>{
119
+ const op = await store.getOp(kind, subjectID);
120
+ if (op == null || op.deleted !== 0) {
121
+ throw new CredentialGrantNotReadable(keyID, `no live op for ${kind} ${subjectID}`);
122
+ }
123
+ return op.op_jwt;
124
+ };
125
+ const key = await store.getKey(keyID);
126
+ if (key == null) {
127
+ throw new CredentialGrantNotReadable(keyID, 'no key row');
128
+ }
129
+ const recipient = normalizeDID(recipientWrappableDID);
130
+ const wrappings = await store.listWrappings(keyID, key.version);
131
+ const wrapping = wrappings.find((candidate)=>candidate.recipient_did != null && normalizeDID(candidate.recipient_did) === recipient);
132
+ const wrappingRecipient = wrapping?.recipient_did;
133
+ if (wrapping == null || wrappingRecipient == null) {
134
+ throw new CredentialGrantNotReadable(keyID, `no wrapping for ${recipient} at v${key.version}`);
135
+ }
136
+ const entries = await store.listEntries(keyID);
137
+ const carriedWrapping = {
138
+ wrappingID: wrapping.wrapping_id,
139
+ factors: wrapping.factors,
140
+ iv: toB64U(wrapping.iv),
141
+ wrappedKey: toB64U(wrapping.wrapped_key),
142
+ recipientDID: wrappingRecipient,
143
+ op: await readOp('wrapping', wrapping.wrapping_id)
144
+ };
145
+ // Only the granted version. An entry under a retired one is unreadable with
146
+ // this wrapping, and shipping it would be ciphertext the recipient is
147
+ // structurally unable to open.
148
+ const carriedEntries = await Promise.all(entries.filter((entry)=>entry.key_version === key.version).map(async (entry)=>({
149
+ entryID: entry.entry_id,
150
+ iv: toB64U(entry.iv),
151
+ ciphertext: toB64U(entry.ciphertext),
152
+ hlc: entry.hlc,
153
+ op: await readOp('entry', entry.entry_id)
154
+ })));
155
+ return {
156
+ keyID,
157
+ keyVersion: key.version,
158
+ suite: key.suite,
159
+ ownerDID: key.owner_did,
160
+ keyOp: await readOp('key', keyID),
161
+ keyBranches: (await store.listKeyBranches(keyID, key.version)).map((branch)=>branch.op_jwt),
162
+ wrapping: carriedWrapping,
163
+ entries: carriedEntries
164
+ };
165
+ }
@@ -7,8 +7,8 @@ import { processBroadcast } from './broadcast.js';
7
7
  * broadcast responder writes a reply for whatever a handler returns, and the
8
8
  * requester's gather drops replies that carried an error and keeps the rest. So a
9
9
  * decline bounds the RESULT, not the traffic — every member still publishes one
10
- * reply frame — and the spec's "only matching devices reply" is true of what a
11
- * gather reports, not of what crosses the hub.
10
+ * reply frame — and "only matching devices reply" is true of what a gather
11
+ * reports, not of what crosses the hub.
12
12
  */ class PeerQueryDeclined extends Error {
13
13
  constructor(reason){
14
14
  super(`peer/query declined: ${reason}`);
@@ -100,6 +100,29 @@ import { processBroadcast } from './broadcast.js';
100
100
  hlc: data.hlc,
101
101
  auth: data.auth
102
102
  }, groupID)),
103
+ // Carried back verbatim, for the same reason the access-default frames
104
+ // are: the token covers digests over these exact bytes, so a handler that
105
+ // filled a field in from context would hand `processBroadcast` a frame its
106
+ // own signature no longer describes.
107
+ 'control/credentialKeyGrant': ({ data })=>safeApply(()=>processBroadcast(params, {
108
+ type: 'credential:key-grant',
109
+ keyID: data.keyID,
110
+ keyVersion: data.keyVersion,
111
+ suite: data.suite,
112
+ ownerDID: data.ownerDID,
113
+ keyOp: data.keyOp,
114
+ keyBranches: data.keyBranches,
115
+ wrapping: {
116
+ wrappingID: data.wrapping.wrappingID,
117
+ factors: data.wrapping.factors,
118
+ iv: data.wrapping.iv,
119
+ wrappedKey: data.wrapping.wrappedKey,
120
+ recipientDID: data.wrapping.recipientDID,
121
+ op: data.wrapping.op
122
+ },
123
+ entries: data.entries,
124
+ auth: data.auth
125
+ }, groupID)),
103
126
  'control/groupLeaveRequest': ({ data })=>safeApply(()=>processBroadcast(params, {
104
127
  type: 'group:leaveRequest',
105
128
  groupID,
@@ -36,6 +36,11 @@ export type CreateGroupMLSParams = {
36
36
  * timestamps (the second would silently lose under last-writer-wins).
37
37
  */
38
38
  hlc: HLC;
39
+ /**
40
+ * How far into the future a folded control entry's `ord` may sit before its
41
+ * clock merge is refused — see `LedgerIngestParams.maxDriftMS`.
42
+ */
43
+ maxDriftMS?: number;
39
44
  groupID: string;
40
45
  /**
41
46
  * Emitter for the domain events a landed commit's control entries drive
@@ -5,8 +5,8 @@ import { getP2PStore } from '@kubun/store-p2p';
5
5
  import { CommitRejectedError, createRecoveryRequest, joinGroupExternal, MissingLedgerEntriesError, openSealedGroupInfo, openSealedLedger, readGroupAnchor, readMessageEpoch, sealGroupInfo, sealLedger } from '@kumiai/mls';
6
6
  import { fromB64 } from '@sozai/codec';
7
7
  import { toISO } from '../context/types.js';
8
- import { bootstrapGroupLedger } from './broadcast.js';
9
8
  import { readRecoverySecret } from './group-anchor.js';
9
+ import { bootstrapGroupLedger } from './ledger-adopt.js';
10
10
  import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
11
11
  import { entriesRetainedByCommit, foldCommittedLedgerEntries } from './ledger-commit-fold.js';
12
12
  import { DecryptError } from './mls-receive-errors.js';
@@ -383,6 +383,9 @@ import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
383
383
  p2pStore,
384
384
  groupID,
385
385
  hlc,
386
+ ...params.maxDriftMS != null ? {
387
+ maxDriftMS: params.maxDriftMS
388
+ } : {},
386
389
  logger
387
390
  });
388
391
  return {
@@ -12,7 +12,7 @@ import { type DeviceHub, type ReconnectingDeviceHub } from '../hub/hub-like.js';
12
12
  import type { SyncProtocol } from '../protocol.js';
13
13
  import type { ForwardingConfig } from '../sync/forwarder.js';
14
14
  import type { LedgerCatchupOptions, LedgerCatchupSummary, RejoinResult, StoreUnreadableMode } from '../types.js';
15
- import type { GroupBroadcastMessage } from './broadcast.js';
15
+ import type { GroupBroadcastMessage } from './broadcast-message.js';
16
16
  import { type SettleLostControlRequestDeps } from './commit-adoption.js';
17
17
  import type { P2PEventEmitter } from './events.js';
18
18
  import type { GroupHandleRegistry } from './group-handle-registry.js';
@@ -60,6 +60,12 @@ export type GroupPeerManagerParams = {
60
60
  graph: GraphInternals;
61
61
  /** Device-wide monotonic clock shared with the engine and MLS receive path. */
62
62
  hlc: HLC;
63
+ /**
64
+ * The engine's future-drift bound, forwarded to the apply path. Optional: a
65
+ * suite that builds a manager by hand falls back to the same default the
66
+ * engine does, so absence is never an absent bound.
67
+ */
68
+ maxDriftMS?: number;
63
69
  /** Authenticated DID of this device (normalized). */
64
70
  localDID: string;
65
71
  /**
@@ -1,3 +1,4 @@
1
+ import { CREDENTIAL_STORE, getCredentialStore } from '@kubun/store-credential';
1
2
  import { getDelegationStore } from '@kubun/store-delegation';
2
3
  import { getP2PStore } from '@kubun/store-p2p';
3
4
  import { readGroupAnchor } from '@kumiai/mls';
@@ -6,9 +7,10 @@ import { createHubLike } from '../hub/hub-like.js';
6
7
  import { createLoopbackLogHub } from '../hub/loopback-log-hub.js';
7
8
  import { createHubServerDIDResolver } from '../hub/server-did.js';
8
9
  import { createTunnelListeners } from '../sync/tunnel-listeners.js';
10
+ import { applyAccessDefaultSetToken } from './access-default-apply.js';
9
11
  import { createAnchorStore } from './anchor-store.js';
10
12
  import { createAppCursorStore } from './app-cursor-store.js';
11
- import { applyAccessDefaultSetToken, bootstrapGroupLedger, processBroadcast } from './broadcast.js';
13
+ import { processBroadcast } from './broadcast.js';
12
14
  import { reprojectGroupSettings } from './circle-projection.js';
13
15
  import { adoptCommitJournalBlob, readJournalRequestID, settleLostControlRequest } from './commit-adoption.js';
14
16
  import { createCommitJournal } from './commit-journal.js';
@@ -18,7 +20,9 @@ import { buildGroupHandlers } from './group-handlers.js';
18
20
  import { createGroupMLS } from './group-mls.js';
19
21
  import { groupProtocols } from './group-protocols.js';
20
22
  import { ledgerEntryDigest } from './ledger.js';
23
+ import { bootstrapGroupLedger } from './ledger-adopt.js';
21
24
  import { createPeerPresence } from './peer-presence.js';
25
+ import { CREDENTIAL_SYNC_PROTOCOL, CREDENTIAL_SYNC_VERSION } from './peer-selection.js';
22
26
  const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
23
27
  /**
24
28
  * Send-side union → procedure mapping — the inverse of `buildGroupHandlers`.
@@ -91,6 +95,23 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
91
95
  type: message.type
92
96
  });
93
97
  return;
98
+ case 'credential:key-grant':
99
+ // Every field rides, including the `ownerDID` the receiver ignores: the
100
+ // token digests the wrapping and the entries as they are carried, so a
101
+ // payload that dropped or rewrote one produces a frame the receiver
102
+ // verifies and correctly rejects.
103
+ await peer.protocol('control').dispatch('control/credentialKeyGrant', {
104
+ keyID: message.keyID,
105
+ keyVersion: message.keyVersion,
106
+ suite: message.suite,
107
+ ownerDID: message.ownerDID,
108
+ keyOp: message.keyOp,
109
+ keyBranches: message.keyBranches,
110
+ wrapping: message.wrapping,
111
+ entries: message.entries,
112
+ auth: message.auth
113
+ });
114
+ return;
94
115
  default:
95
116
  {
96
117
  // Exhaustiveness gate. A broadcast type with no case here used to fall off
@@ -440,9 +461,17 @@ export function createGroupPeerManager(params) {
440
461
  getP2PStore(params.stores),
441
462
  getDelegationStore(params.stores)
442
463
  ]);
464
+ // Registered by `@kubun/plugin-credential`, not here, so its absence is
465
+ // "that plugin is not installed" rather than a fault. Asked through
466
+ // `hasStore` because `getStore` on an unregistered name throws, and a device
467
+ // with no credential plugin must still bind its peers.
468
+ const credentialStore = params.stores.hasStore(CREDENTIAL_STORE) ? await getCredentialStore(params.stores) : undefined;
443
469
  return {
444
470
  p2pStore,
445
471
  delegationStore,
472
+ ...credentialStore != null ? {
473
+ credentialStore
474
+ } : {},
446
475
  graphStore: params.graphStore,
447
476
  graph: params.graph,
448
477
  selfDID: params.localDID,
@@ -455,6 +484,7 @@ export function createGroupPeerManager(params) {
455
484
  scheduleBroadcast: params.scheduleBroadcast,
456
485
  emitter: params.emitter,
457
486
  hlc: params.hlc,
487
+ maxDriftMS: params.maxDriftMS,
458
488
  // Reads the genesis anchor baked into the MLS GroupContext so role
459
489
  // projection on a received ledger entry runs against the authenticated
460
490
  // epoch-0 creator. Null for a group with no anchor (e.g. external).
@@ -547,6 +577,9 @@ export function createGroupPeerManager(params) {
547
577
  stores: params.stores,
548
578
  identity: params.identity,
549
579
  hlc: params.hlc,
580
+ ...params.maxDriftMS != null ? {
581
+ maxDriftMS: params.maxDriftMS
582
+ } : {},
550
583
  groupID,
551
584
  // A received commit's control entries fold into this device's
552
585
  // projections, so the changes they carry reach subscribers as the same
@@ -791,6 +824,16 @@ export function createGroupPeerManager(params) {
791
824
  }
792
825
  },
793
826
  getGroupEpoch: (groupID)=>params.registry.groupEpoch(groupID) ?? undefined,
827
+ // Read per announce: a credential store registered after the profile was set
828
+ // still makes this device advertise the lane, and a device without one never
829
+ // does — the opt-out is the absence of the store.
830
+ deviceCapabilities: ()=>params.stores.hasStore(CREDENTIAL_STORE) ? [
831
+ {
832
+ protocol: CREDENTIAL_SYNC_PROTOCOL,
833
+ version: CREDENTIAL_SYNC_VERSION,
834
+ transports: null
835
+ }
836
+ ] : [],
794
837
  publish: broadcastToPeers,
795
838
  // Gathered over every hub-peer of the group, concurrently: `timeoutMs` bounds
796
839
  // each window, so in sequence a dark hub burns its whole window before the
@@ -197,6 +197,119 @@ export declare const controlProtocol: {
197
197
  readonly additionalProperties: false;
198
198
  };
199
199
  };
200
+ readonly 'control/credentialKeyGrant': {
201
+ readonly type: "event";
202
+ readonly retain: "log";
203
+ readonly description: "Hand one co-member what it needs to open a credential key: the key's public record, the single wrapping addressed to that member, and every entry ciphertext at that version. Group-wide because confidentiality is content-level; only the addressed DID applies it. `auth` signs identifiers and content digests, and the receiver recomputes both from this frame.";
204
+ readonly data: {
205
+ readonly type: "object";
206
+ readonly properties: {
207
+ readonly keyID: {
208
+ readonly type: "string";
209
+ };
210
+ readonly keyVersion: {
211
+ readonly type: "number";
212
+ };
213
+ readonly suite: {
214
+ readonly type: "number";
215
+ };
216
+ readonly ownerDID: {
217
+ readonly type: "string";
218
+ readonly description: "Claimed owner. A mirror — the receiver stores the `auth` issuer instead.";
219
+ };
220
+ readonly keyOp: {
221
+ readonly type: "string";
222
+ readonly description: "The granter's signed op for the key row, stored verbatim. The key row rides inline on this frame, so its op has nowhere else to hang.";
223
+ };
224
+ readonly keyBranches: {
225
+ readonly type: "array";
226
+ readonly items: {
227
+ readonly type: "string";
228
+ };
229
+ readonly description: "The proven key-op JWTs that introduced this key version's branches.";
230
+ };
231
+ readonly wrapping: {
232
+ readonly type: "object";
233
+ readonly properties: {
234
+ readonly wrappingID: {
235
+ readonly type: "string";
236
+ };
237
+ readonly factors: {
238
+ readonly type: "array";
239
+ readonly items: {
240
+ readonly type: "object";
241
+ readonly properties: {
242
+ readonly kind: {
243
+ readonly type: "string";
244
+ };
245
+ readonly params: {
246
+ readonly type: "object";
247
+ readonly description: "Per-kind PUBLIC material — a salt or an ephemeral public key.";
248
+ readonly additionalProperties: true;
249
+ };
250
+ };
251
+ readonly required: readonly ["kind", "params"];
252
+ readonly additionalProperties: false;
253
+ };
254
+ readonly description: "ORDERED, and bound into the wrapping AAD: a permuted copy will not open.";
255
+ };
256
+ readonly iv: {
257
+ readonly type: "string";
258
+ readonly description: "base64url";
259
+ };
260
+ readonly wrappedKey: {
261
+ readonly type: "string";
262
+ readonly description: "base64url";
263
+ };
264
+ readonly recipientDID: {
265
+ readonly type: "string";
266
+ readonly description: "Un-normalized: a key-resolution input, not an identity to compare.";
267
+ };
268
+ readonly op: {
269
+ readonly type: "string";
270
+ readonly description: "The granter's signed op for this row, stored verbatim — the receiver never mints its own.";
271
+ };
272
+ };
273
+ readonly required: readonly ["wrappingID", "factors", "iv", "wrappedKey", "recipientDID", "op"];
274
+ readonly additionalProperties: false;
275
+ };
276
+ readonly entries: {
277
+ readonly type: "array";
278
+ readonly items: {
279
+ readonly type: "object";
280
+ readonly properties: {
281
+ readonly entryID: {
282
+ readonly type: "string";
283
+ };
284
+ readonly iv: {
285
+ readonly type: "string";
286
+ readonly description: "base64url";
287
+ };
288
+ readonly ciphertext: {
289
+ readonly type: "string";
290
+ readonly description: "base64url";
291
+ };
292
+ readonly hlc: {
293
+ readonly type: "string";
294
+ };
295
+ readonly op: {
296
+ readonly type: "string";
297
+ readonly description: "The granter's signed op for this row, stored verbatim.";
298
+ };
299
+ };
300
+ readonly required: readonly ["entryID", "iv", "ciphertext", "hlc", "op"];
301
+ readonly additionalProperties: false;
302
+ };
303
+ };
304
+ readonly auth: {
305
+ readonly type: "string";
306
+ readonly description: "Signed credential key-grant token.";
307
+ };
308
+ };
309
+ readonly required: readonly ["keyID", "keyVersion", "suite", "ownerDID", "keyOp", "keyBranches", "wrapping", "entries", "auth"];
310
+ readonly additionalProperties: false;
311
+ };
312
+ };
200
313
  readonly 'control/groupLeaveRequest': {
201
314
  readonly type: "event";
202
315
  readonly retain: "ephemeral";
@@ -380,6 +493,7 @@ export type DelegationShareData = FromSchema<(typeof controlProtocol)['control/d
380
493
  export type DelegationRevokeData = FromSchema<(typeof controlProtocol)['control/delegationRevoke']['data']>;
381
494
  export type AccessDefaultSetData = FromSchema<(typeof controlProtocol)['control/accessDefaultSet']['data']>;
382
495
  export type AccessDefaultRemoveData = FromSchema<(typeof controlProtocol)['control/accessDefaultRemove']['data']>;
496
+ export type CredentialKeyGrantData = FromSchema<(typeof controlProtocol)['control/credentialKeyGrant']['data']>;
383
497
  export type GroupLeaveRequestData = FromSchema<(typeof controlProtocol)['control/groupLeaveRequest']['data']>;
384
498
  export type MutationApplyData = FromSchema<(typeof syncProtocol)['sync/mutationApply']['data']>;
385
499
  export type PeerAnnounceData = FromSchema<(typeof peerProtocol)['peer/announce']['data']>;
@@ -579,6 +693,119 @@ export declare const groupProtocols: {
579
693
  readonly additionalProperties: false;
580
694
  };
581
695
  };
696
+ readonly 'control/credentialKeyGrant': {
697
+ readonly type: "event";
698
+ readonly retain: "log";
699
+ readonly description: "Hand one co-member what it needs to open a credential key: the key's public record, the single wrapping addressed to that member, and every entry ciphertext at that version. Group-wide because confidentiality is content-level; only the addressed DID applies it. `auth` signs identifiers and content digests, and the receiver recomputes both from this frame.";
700
+ readonly data: {
701
+ readonly type: "object";
702
+ readonly properties: {
703
+ readonly keyID: {
704
+ readonly type: "string";
705
+ };
706
+ readonly keyVersion: {
707
+ readonly type: "number";
708
+ };
709
+ readonly suite: {
710
+ readonly type: "number";
711
+ };
712
+ readonly ownerDID: {
713
+ readonly type: "string";
714
+ readonly description: "Claimed owner. A mirror — the receiver stores the `auth` issuer instead.";
715
+ };
716
+ readonly keyOp: {
717
+ readonly type: "string";
718
+ readonly description: "The granter's signed op for the key row, stored verbatim. The key row rides inline on this frame, so its op has nowhere else to hang.";
719
+ };
720
+ readonly keyBranches: {
721
+ readonly type: "array";
722
+ readonly items: {
723
+ readonly type: "string";
724
+ };
725
+ readonly description: "The proven key-op JWTs that introduced this key version's branches.";
726
+ };
727
+ readonly wrapping: {
728
+ readonly type: "object";
729
+ readonly properties: {
730
+ readonly wrappingID: {
731
+ readonly type: "string";
732
+ };
733
+ readonly factors: {
734
+ readonly type: "array";
735
+ readonly items: {
736
+ readonly type: "object";
737
+ readonly properties: {
738
+ readonly kind: {
739
+ readonly type: "string";
740
+ };
741
+ readonly params: {
742
+ readonly type: "object";
743
+ readonly description: "Per-kind PUBLIC material — a salt or an ephemeral public key.";
744
+ readonly additionalProperties: true;
745
+ };
746
+ };
747
+ readonly required: readonly ["kind", "params"];
748
+ readonly additionalProperties: false;
749
+ };
750
+ readonly description: "ORDERED, and bound into the wrapping AAD: a permuted copy will not open.";
751
+ };
752
+ readonly iv: {
753
+ readonly type: "string";
754
+ readonly description: "base64url";
755
+ };
756
+ readonly wrappedKey: {
757
+ readonly type: "string";
758
+ readonly description: "base64url";
759
+ };
760
+ readonly recipientDID: {
761
+ readonly type: "string";
762
+ readonly description: "Un-normalized: a key-resolution input, not an identity to compare.";
763
+ };
764
+ readonly op: {
765
+ readonly type: "string";
766
+ readonly description: "The granter's signed op for this row, stored verbatim — the receiver never mints its own.";
767
+ };
768
+ };
769
+ readonly required: readonly ["wrappingID", "factors", "iv", "wrappedKey", "recipientDID", "op"];
770
+ readonly additionalProperties: false;
771
+ };
772
+ readonly entries: {
773
+ readonly type: "array";
774
+ readonly items: {
775
+ readonly type: "object";
776
+ readonly properties: {
777
+ readonly entryID: {
778
+ readonly type: "string";
779
+ };
780
+ readonly iv: {
781
+ readonly type: "string";
782
+ readonly description: "base64url";
783
+ };
784
+ readonly ciphertext: {
785
+ readonly type: "string";
786
+ readonly description: "base64url";
787
+ };
788
+ readonly hlc: {
789
+ readonly type: "string";
790
+ };
791
+ readonly op: {
792
+ readonly type: "string";
793
+ readonly description: "The granter's signed op for this row, stored verbatim.";
794
+ };
795
+ };
796
+ readonly required: readonly ["entryID", "iv", "ciphertext", "hlc", "op"];
797
+ readonly additionalProperties: false;
798
+ };
799
+ };
800
+ readonly auth: {
801
+ readonly type: "string";
802
+ readonly description: "Signed credential key-grant token.";
803
+ };
804
+ };
805
+ readonly required: readonly ["keyID", "keyVersion", "suite", "ownerDID", "keyOp", "keyBranches", "wrapping", "entries", "auth"];
806
+ readonly additionalProperties: false;
807
+ };
808
+ };
582
809
  readonly 'control/groupLeaveRequest': {
583
810
  readonly type: "event";
584
811
  readonly retain: "ephemeral";