@kubun/plugin-p2p 0.14.0 → 0.15.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.
Files changed (45) hide show
  1. package/lib/context/peer.js +250 -3
  2. package/lib/context/sync.js +134 -25
  3. package/lib/context/types.d.ts +7 -0
  4. package/lib/groups/broadcast-message.d.ts +29 -0
  5. package/lib/groups/broadcast.d.ts +11 -1
  6. package/lib/groups/broadcast.js +44 -2
  7. package/lib/groups/credential-apply.d.ts +64 -2
  8. package/lib/groups/credential-apply.js +215 -30
  9. package/lib/groups/credential-grant.d.ts +22 -0
  10. package/lib/groups/credential-grant.js +76 -2
  11. package/lib/groups/credential-manifest-token.d.ts +31 -0
  12. package/lib/groups/credential-manifest-token.js +49 -0
  13. package/lib/groups/credential-readiness.d.ts +69 -0
  14. package/lib/groups/credential-readiness.js +172 -0
  15. package/lib/groups/credential-wrapping-deps.d.ts +23 -0
  16. package/lib/groups/credential-wrapping-deps.js +25 -0
  17. package/lib/groups/grantor-authority.d.ts +65 -0
  18. package/lib/groups/grantor-authority.js +107 -0
  19. package/lib/groups/group-handlers.js +7 -0
  20. package/lib/groups/group-peer-manager.d.ts +25 -0
  21. package/lib/groups/group-peer-manager.js +71 -0
  22. package/lib/groups/group-protocols.d.ts +47 -0
  23. package/lib/groups/group-protocols.js +28 -0
  24. package/lib/hub/wiring.d.ts +24 -0
  25. package/lib/hub/wiring.js +17 -1
  26. package/lib/index.d.ts +14 -0
  27. package/lib/index.js +57 -1
  28. package/lib/protocol.d.ts +30 -0
  29. package/lib/protocol.js +36 -0
  30. package/lib/schema.js +78 -1
  31. package/lib/sync/handlers.js +21 -2
  32. package/lib/sync/held-delegations.d.ts +14 -0
  33. package/lib/sync/held-delegations.js +34 -0
  34. package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
  35. package/lib/sync/hub-tunnel-service-listener.js +289 -0
  36. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  37. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  38. package/lib/sync/service-tunnel-listeners.d.ts +35 -0
  39. package/lib/sync/service-tunnel-listeners.js +165 -0
  40. package/lib/sync/sync-manager.d.ts +7 -0
  41. package/lib/sync/sync-manager.js +4 -1
  42. package/lib/sync/tunnel-topics.d.ts +19 -1
  43. package/lib/sync/tunnel-topics.js +7 -3
  44. package/lib/types.d.ts +172 -0
  45. package/package.json +48 -47
@@ -0,0 +1,100 @@
1
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
3
+ import { createGroupCrypto } from '../groups/group-crypto.js';
4
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ import { createSenderScopedHubView } from '../hub/sender-scoped-hub-view.js';
6
+ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
7
+ /**
8
+ * Dial side of the SERVICE tunnel — the same directed hub-tunnel machinery as
9
+ * {@link HubTunnelSyncProvider}, riding a distinct lane so a service session to
10
+ * a peer never collides with a sync session's topics or ratchet generation.
11
+ */ export class HubTunnelServiceProvider {
12
+ #hub;
13
+ #registry;
14
+ #groupID;
15
+ #localDID;
16
+ #peerDID;
17
+ #runtime;
18
+ #idleTimeoutMs;
19
+ #logger;
20
+ constructor(params){
21
+ this.#hub = params.hub;
22
+ this.#registry = params.registry;
23
+ this.#groupID = params.groupID;
24
+ this.#localDID = params.localDID;
25
+ this.#peerDID = params.peerDID;
26
+ this.#runtime = params.runtime;
27
+ this.#idleTimeoutMs = params.idleTimeoutMs;
28
+ this.#logger = params.logger;
29
+ }
30
+ /**
31
+ * Build a fresh client transport for one tunnel service session.
32
+ *
33
+ * Topics are group- and role-scoped, derived from the current MLS epoch
34
+ * secret on the service lane: outbound frames publish to the peer's
35
+ * responder topic, and the transport subscribes to this device's own dialer
36
+ * topic. Topics rotate with the epoch, so callers create a new transport per
37
+ * session.
38
+ */ async createServiceTransport(signal) {
39
+ const sessionID = this.#runtime.getRandomID();
40
+ const encryptor = new MLSEncryptor({
41
+ registry: this.#registry,
42
+ groupID: this.#groupID
43
+ });
44
+ // The seed epoch is unread here: this port never classifies commit frames,
45
+ // and `exportSecret()` below records the live epoch before `epoch()` is
46
+ // asked for it. Only a peer that opens a receive drain needs a real seed.
47
+ const crypto = createGroupCrypto({
48
+ registry: this.#registry,
49
+ groupID: this.#groupID,
50
+ initialEpoch: 0,
51
+ runtime: this.#runtime
52
+ });
53
+ const secret = await crypto.exportSecret(APP_TOPIC_LABEL);
54
+ const epoch = crypto.epoch();
55
+ // SERVICE lane — the only substantive difference from the sync provider.
56
+ const sendTopicID = tunnelTopic(secret, epoch, 'responder', this.#peerDID, SERVICE_TUNNEL_PROTOCOL);
57
+ const receiveTopicID = tunnelTopic(secret, epoch, 'dialer', this.#localDID, SERVICE_TUNNEL_PROTOCOL);
58
+ // Bound once and carried by every line this session logs — the transport's
59
+ // own idle timeout raises a bare `TimeoutInterruption` with none of this
60
+ // context.
61
+ const sessionLogger = this.#logger?.with({
62
+ role: 'dialer',
63
+ lane: 'service',
64
+ groupID: this.#groupID,
65
+ peerDID: this.#peerDID,
66
+ sessionID,
67
+ epoch,
68
+ receiveTopicID
69
+ });
70
+ sessionLogger?.debug('tunnel session opening');
71
+ const transport = createEncryptedHubTunnelTransport({
72
+ // The peer-scoped view, never the device hub: two sessions open at once
73
+ // share the same inbox topic, and MLS consumes a ratchet generation per
74
+ // open, destroying the frame for whichever session loses the race.
75
+ // The idle timeout doubles as the publish bound so "no receive" and "no
76
+ // send" give up on the same promise rather than drifting apart.
77
+ hub: createSenderScopedHubView({
78
+ hub: this.#hub,
79
+ peerDID: this.#peerDID,
80
+ ...this.#idleTimeoutMs == null ? {} : {
81
+ publishTimeoutMs: this.#idleTimeoutMs
82
+ }
83
+ }),
84
+ encryptor,
85
+ groupID: this.#groupID,
86
+ sessionID,
87
+ localDID: this.#localDID,
88
+ sendTopicID,
89
+ receiveTopicID,
90
+ signal,
91
+ idleTimeoutMs: this.#idleTimeoutMs
92
+ });
93
+ // Close is as load-bearing as open: an unreachable peer looks like a
94
+ // session ending without having received anything.
95
+ transport.events.on('disposed', ()=>{
96
+ sessionLogger?.debug('tunnel session closed');
97
+ });
98
+ return transport;
99
+ }
100
+ }
@@ -0,0 +1,35 @@
1
+ import type { OwnIdentity } from '@kokuin/token';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { ServiceConfig } from '@kubun/plugin-service-api';
5
+ import type { MailboxHub } from '@kumiai/hub-tunnel';
6
+ import type { Runtime } from '@sozai/runtime';
7
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
8
+ import { type ServeService } from './hub-tunnel-service-listener.js';
9
+ import type { TunnelListeners } from './tunnel-listeners.js';
10
+ export type ServiceTunnelListenersParams = {
11
+ stores: StoreProvider;
12
+ registry: GroupHandleRegistry;
13
+ identity: OwnIdentity;
14
+ localDID: string;
15
+ runtime: Runtime;
16
+ /** Injected `plugin-service-server` `serve()` — see {@link ServeService}. */
17
+ serve: ServeService;
18
+ /** Which services to serve, forwarded verbatim to `serve` on every spawn. */
19
+ services: Record<string, ServiceConfig>;
20
+ /** The group's device-wide drain, or `undefined` when no hub is bound. */
21
+ tunnelHub: (groupID: string) => MailboxHub | undefined;
22
+ logger?: Logger;
23
+ idleTimeoutMs?: number;
24
+ };
25
+ /**
26
+ * The answering half of the service tunnel — one `HubTunnelServiceListener` per
27
+ * co-member per group, so a device dialing on the service lane finds something
28
+ * draining the mailbox and handing the session to the injected `serve()`.
29
+ *
30
+ * Structurally identical to {@link createTunnelListeners} (the sync lane's
31
+ * answering half): same groupID→peerDID map, same epoch/hub-change teardown,
32
+ * same per-group serialized reconciles — only the listener class and what's
33
+ * threaded into it differ, so the two lanes never share an instance or ratchet.
34
+ */
35
+ export declare function createServiceTunnelListeners(params: ServiceTunnelListenersParams): TunnelListeners;
@@ -0,0 +1,165 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { getP2PStore } from '@kubun/store-p2p';
3
+ import { HubTunnelServiceListener } from './hub-tunnel-service-listener.js';
4
+ /**
5
+ * The answering half of the service tunnel — one `HubTunnelServiceListener` per
6
+ * co-member per group, so a device dialing on the service lane finds something
7
+ * draining the mailbox and handing the session to the injected `serve()`.
8
+ *
9
+ * Structurally identical to {@link createTunnelListeners} (the sync lane's
10
+ * answering half): same groupID→peerDID map, same epoch/hub-change teardown,
11
+ * same per-group serialized reconciles — only the listener class and what's
12
+ * threaded into it differ, so the two lanes never share an instance or ratchet.
13
+ */ export function createServiceTunnelListeners(params) {
14
+ const selfDID = normalizeDID(params.localDID);
15
+ const { logger } = params;
16
+ // groupID → peerDID → its listener.
17
+ const listeners = new Map();
18
+ // Which drain each group's listeners were built on. A listener holds its hub
19
+ // for life, so a group that switches hubs needs new ones — and the peer set
20
+ // alone cannot show that, since the same members are still wanted.
21
+ const attachedHubs = new Map();
22
+ // And which epoch they derived their topics at. A live listener's subscription
23
+ // is pinned to its spawn epoch; only a disposed transport re-derives, so a
24
+ // group whose epoch moved would otherwise keep draining a stale topic.
25
+ const attachedEpochs = new Map();
26
+ // Reconciles of one group run in sequence: an epoch burst fires several, and
27
+ // two interleaved passes over the same map would each start a listener the
28
+ // other has not recorded yet.
29
+ const queues = new Map();
30
+ let disposed = false;
31
+ const stopGroup = async (groupID)=>{
32
+ attachedHubs.delete(groupID);
33
+ attachedEpochs.delete(groupID);
34
+ const group = listeners.get(groupID);
35
+ if (group == null) {
36
+ return;
37
+ }
38
+ listeners.delete(groupID);
39
+ await Promise.allSettled([
40
+ ...group.values()
41
+ ].map((listener)=>listener.stop()));
42
+ };
43
+ const runReconcile = async (groupID)=>{
44
+ if (disposed) {
45
+ return;
46
+ }
47
+ const store = await getP2PStore(params.stores);
48
+ const profile = await store.getLocalPeerProfile(selfDID);
49
+ if (profile == null) {
50
+ await stopGroup(groupID);
51
+ return;
52
+ }
53
+ const hub = params.tunnelHub(groupID);
54
+ if (hub == null) {
55
+ // No hub bound: there is no mailbox to drain. A binding arriving later
56
+ // reconciles again.
57
+ await stopGroup(groupID);
58
+ return;
59
+ }
60
+ const epoch = params.registry.groupEpoch(groupID);
61
+ if (attachedHubs.get(groupID) !== hub || attachedEpochs.get(groupID) !== epoch) {
62
+ await stopGroup(groupID);
63
+ }
64
+ attachedHubs.set(groupID, hub);
65
+ attachedEpochs.set(groupID, epoch);
66
+ const members = await store.listGroupMembers(groupID);
67
+ const wanted = new Set(members.map((member)=>normalizeDID(member.member_did)).filter((did)=>did !== selfDID));
68
+ let group = listeners.get(groupID);
69
+ if (group == null) {
70
+ group = new Map();
71
+ listeners.set(groupID, group);
72
+ }
73
+ for (const [peerDID, listener] of group){
74
+ if (!wanted.has(peerDID)) {
75
+ group.delete(peerDID);
76
+ try {
77
+ await listener.stop();
78
+ } catch (error) {
79
+ logger?.warn('service tunnel listener stop failed', {
80
+ groupID,
81
+ peerDID,
82
+ error
83
+ });
84
+ }
85
+ }
86
+ }
87
+ // Every await above is a point where the group could have been left or the
88
+ // whole thing disposed. A pass that continued past one would repopulate
89
+ // `listeners` with fresh, STARTED listeners for a group this device is no
90
+ // longer in, and they would drain that group's inbox topic indefinitely —
91
+ // nothing later removes what `removeGroup` already walked past.
92
+ if (disposed || listeners.get(groupID) !== group) {
93
+ return;
94
+ }
95
+ for (const peerDID of wanted){
96
+ if (group.has(peerDID)) {
97
+ continue;
98
+ }
99
+ const listener = new HubTunnelServiceListener({
100
+ hub,
101
+ registry: params.registry,
102
+ groupID,
103
+ localDID: selfDID,
104
+ peerDID,
105
+ runtime: params.runtime,
106
+ serve: params.serve,
107
+ services: params.services,
108
+ ...params.idleTimeoutMs != null ? {
109
+ idleTimeoutMs: params.idleTimeoutMs
110
+ } : {},
111
+ ...logger != null ? {
112
+ logger
113
+ } : {}
114
+ });
115
+ group.set(peerDID, listener);
116
+ listener.start();
117
+ }
118
+ };
119
+ const reconcile = (groupID)=>{
120
+ const queued = (queues.get(groupID) ?? Promise.resolve()).then(()=>runReconcile(groupID), ()=>runReconcile(groupID));
121
+ // Never let one group's failure reject a caller that only asked for a
122
+ // reconcile — the next signal retries, and a throw here would surface on
123
+ // whichever unrelated lane happened to trigger it.
124
+ const settled = queued.catch((error)=>{
125
+ logger?.warn('service tunnel listener reconcile failed', {
126
+ groupID,
127
+ error
128
+ });
129
+ });
130
+ queues.set(groupID, settled);
131
+ return settled;
132
+ };
133
+ return {
134
+ reconcile,
135
+ reconcileAll: async (groupIDs)=>{
136
+ await Promise.all(groupIDs.map((groupID)=>reconcile(groupID)));
137
+ },
138
+ removeGroup: async (groupID)=>{
139
+ // Await the in-flight reconcile BEFORE tearing down. Dropping the queue
140
+ // entry does not stop the pass already running: it would finish after the
141
+ // stop and leave started listeners behind for a group the device has
142
+ // left. `runReconcile` also re-checks, so this is the pair.
143
+ const queued = queues.get(groupID);
144
+ queues.delete(groupID);
145
+ if (queued != null) {
146
+ await queued.catch(()=>{
147
+ // A failed reconcile is already logged; the teardown proceeds.
148
+ });
149
+ }
150
+ await stopGroup(groupID);
151
+ },
152
+ dispose: async ()=>{
153
+ disposed = true;
154
+ const inFlight = [
155
+ ...queues.values()
156
+ ];
157
+ queues.clear();
158
+ await Promise.allSettled(inFlight);
159
+ const groupIDs = [
160
+ ...listeners.keys()
161
+ ];
162
+ await Promise.allSettled(groupIDs.map((groupID)=>stopGroup(groupID)));
163
+ }
164
+ };
165
+ }
@@ -64,6 +64,13 @@ export type MerkleSyncParams = {
64
64
  * is swallowed — the credential lane must never fail the doc sync.
65
65
  */
66
66
  reconcileCredentialsHeld?: SyncReconcileCredentialsParam['held'];
67
+ /**
68
+ * Owner DIDs to pull the latest durable manifest for, addressed to this
69
+ * device, alongside the same `sync/reconcile-credentials` request — the PULL
70
+ * repair channel independent of LIVE broadcast. Undefined omits `owners`
71
+ * from the wire request entirely, leaving existing behavior unchanged.
72
+ */
73
+ reconcileCredentialOwners?: Array<string>;
67
74
  };
68
75
  export declare class SyncManager {
69
76
  #private;
@@ -136,7 +136,10 @@ export class SyncManager {
136
136
  try {
137
137
  credentialReconcile = await client.request('sync/reconcile-credentials', {
138
138
  param: {
139
- held: params.reconcileCredentialsHeld
139
+ held: params.reconcileCredentialsHeld,
140
+ ...params.reconcileCredentialOwners != null && {
141
+ owners: params.reconcileCredentialOwners
142
+ }
140
143
  }
141
144
  });
142
145
  } catch (error) {
@@ -1,3 +1,21 @@
1
+ /**
2
+ * The lane every directed sync tunnel rides, owned by this package.
3
+ *
4
+ * Deliberately not `inboxTopic`: that derives ONE topic per member, so a device
5
+ * dialing a peer and answering that same peer both read and both write the two
6
+ * members' inboxes. The two roles then share a topic, and sender scope cannot
7
+ * separate them — a peer's reply and that peer's own dial request are equally
8
+ * "from the peer". Whichever of the two opens a frame first consumes its MLS
9
+ * ratchet generation and DESTROYS it for the other, which waits out its idle
10
+ * timeout instead. `inboxTopic` is also `@kumiai/rpc`'s reserved lane, and a
11
+ * host protocol has no business publishing into it.
12
+ */
13
+ export declare const SYNC_TUNNEL_PROTOCOL = "kubun/sync-tunnel/v1";
14
+ /**
15
+ * Distinct protocol label so a service session never shares a ratchet
16
+ * generation with a sync session to the same peer.
17
+ */
18
+ export declare const SERVICE_TUNNEL_PROTOCOL = "kubun/service-tunnel/v1";
1
19
  /**
2
20
  * Which side of a directed session a topic belongs to. A `dialer` topic carries
3
21
  * the responses flowing back to whoever opened the session; a `responder` topic
@@ -17,4 +35,4 @@ export type TunnelRole = 'dialer' | 'responder';
17
35
  * nothing in the sync client opens those, and a session id in the scope would
18
36
  * cost a round trip to agree on before either side could subscribe.
19
37
  */
20
- export declare function tunnelTopic(secret: Uint8Array, epoch: number, role: TunnelRole, ownerDID: string): string;
38
+ export declare function tunnelTopic(secret: Uint8Array, epoch: number, role: TunnelRole, ownerDID: string, protocol?: string): string;
@@ -10,7 +10,11 @@ import { protocolTopic } from '@kumiai/rpc';
10
10
  * ratchet generation and DESTROYS it for the other, which waits out its idle
11
11
  * timeout instead. `inboxTopic` is also `@kumiai/rpc`'s reserved lane, and a
12
12
  * host protocol has no business publishing into it.
13
- */ const SYNC_TUNNEL_PROTOCOL = 'kubun/sync-tunnel/v1';
13
+ */ export const SYNC_TUNNEL_PROTOCOL = 'kubun/sync-tunnel/v1';
14
+ /**
15
+ * Distinct protocol label so a service session never shares a ratchet
16
+ * generation with a sync session to the same peer.
17
+ */ export const SERVICE_TUNNEL_PROTOCOL = 'kubun/service-tunnel/v1';
14
18
  /**
15
19
  * The topic on which `ownerDID` receives the frames addressed to it in `role`.
16
20
  *
@@ -23,6 +27,6 @@ import { protocolTopic } from '@kumiai/rpc';
23
27
  * Two sessions in the same role to the SAME peer at once would still contend —
24
28
  * nothing in the sync client opens those, and a session id in the scope would
25
29
  * cost a round trip to agree on before either side could subscribe.
26
- */ export function tunnelTopic(secret, epoch, role, ownerDID) {
27
- return protocolTopic(secret, epoch, SYNC_TUNNEL_PROTOCOL, `${role}:${ownerDID}`);
30
+ */ export function tunnelTopic(secret, epoch, role, ownerDID, protocol = SYNC_TUNNEL_PROTOCOL) {
31
+ return protocolTopic(secret, epoch, protocol, `${role}:${ownerDID}`);
28
32
  }
package/lib/types.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import type { ServiceProtocol } from '@kubun/plugin-service-api';
2
3
  import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
3
4
  import type { AccessLevel } from '@kubun/store-graph';
4
5
  import type { ControlRequestKind, ControlRequestOutcome } from '@kubun/store-p2p';
6
+ import type { CredentialGrantOutcomeKind } from './groups/credential-grant.js';
5
7
  import type { PeerAvailability, PeerCapability } from './groups/group-protocols.js';
6
8
  import type { PeerConnection } from './peer/connection-registry.js';
7
9
  import type { SyncDirection, SyncProtocol } from './protocol.js';
@@ -392,6 +394,17 @@ export type SyncPeerData = {
392
394
  messagesReceived: number;
393
395
  messagesSent: number;
394
396
  divergentBuckets: number;
397
+ /**
398
+ * Rows the piggybacked credential reconcile moved in: manifests stored,
399
+ * bundles applied, rows tombstoned. Absent both when the lane never ran and
400
+ * when its apply failed (swallowed to keep doc catch-up non-fatal) — not a
401
+ * completeness signal; use the manifest-vs-store readiness query for that.
402
+ */
403
+ credentialReconcileApplied?: {
404
+ manifestsApplied: number;
405
+ applied: number;
406
+ tombstoned: number;
407
+ };
395
408
  };
396
409
  /**
397
410
  * Why an automatic catch-up carried nothing — the distinction all-zero counts
@@ -549,6 +562,12 @@ export type AdmitJoinRequestParams = {
549
562
  * payload's own `did` field is only cross-checked against it.
550
563
  */
551
564
  joinRequest: string;
565
+ /**
566
+ * Delegation tokens delivered to the joiner atomically with admission (e.g.
567
+ * a controller→device grant), via the same InvitePayload.grants path as
568
+ * `requestInvite`.
569
+ */
570
+ grants?: Array<string>;
552
571
  /** See {@link SharePeerGroupParams.sendModels}. */
553
572
  sendModels?: Array<string> | null;
554
573
  /** See {@link SharePeerGroupParams.receiveActivate}. */
@@ -570,6 +589,110 @@ export type JoinPeerGroupParams = {
570
589
  /** The group to join, as discovered from the peer's `connectPeer` group list. */
571
590
  groupID: string;
572
591
  };
592
+ export type GrantDeviceCredentialsParams = {
593
+ /**
594
+ * The MLS group whose roster `recipientDID` must resolve in — the same
595
+ * lookup `grantCredentialKeyToMember` makes to find a wrappable form.
596
+ */
597
+ groupID: string;
598
+ /** The device being granted this batch of `ownerDID`'s active credential keys. */
599
+ recipientDID: string;
600
+ /**
601
+ * The credential owner whose active keys are enumerated and granted.
602
+ * Normalized once and reused for the grant calls and the manifest so both
603
+ * name the same principal.
604
+ */
605
+ ownerDID: string;
606
+ /**
607
+ * Floor for the allocated provisioning epoch (`nextProvisioningEpoch` =
608
+ * `max(stored, minEpoch) + 1`); defaults to 0. Pass a recipient's known
609
+ * held epoch to force the new manifest strictly past it.
610
+ */
611
+ minEpoch?: number;
612
+ };
613
+ /**
614
+ * One key's outcome from a `grantDeviceCredentials` batch — never thrown; the
615
+ * batch reports every key's fate so a caller can retry only what didn't land.
616
+ */
617
+ export type CredentialGrantOutcome = {
618
+ keyID: string;
619
+ /** The key's version after this call, or `null` for `not-held`/`error` outcomes. */
620
+ version: number | null;
621
+ outcome: CredentialGrantOutcomeKind;
622
+ /** Present on `not-held` and `error`; the caught error's message. */
623
+ reason?: string;
624
+ };
625
+ export type CredentialProvisioningStatusParams = {
626
+ /**
627
+ * The credential owner whose per-grantor manifests addressed to THIS device
628
+ * name the keys expected here. Normalized once alongside the viewer.
629
+ */
630
+ ownerDID: string;
631
+ };
632
+ export type CredentialProvisioningStatusData = {
633
+ /** True iff every durable expectation is satisfied — `missing` is empty. */
634
+ complete: boolean;
635
+ /**
636
+ * Expected keyIDs not yet materialized here: absent, unwrapped for this
637
+ * device, or locked behind a wrapping that never decrypt-verifies.
638
+ */
639
+ missing: Array<string>;
640
+ /**
641
+ * Explains a `false` verdict: `'not-initiated'` (no expectations recorded
642
+ * yet), `'pending'` (an attempt in flight, floor not bound), or
643
+ * `'incomplete'` (recorded but not yet satisfied). Absent when `complete`.
644
+ */
645
+ reason?: 'not-initiated' | 'pending' | 'incomplete';
646
+ };
647
+ export type BeginProvisioningExpectationParams = {
648
+ /** The credential owner this expectation tracks provisioning for. */
649
+ ownerDID: string;
650
+ /** The grantor this device expects a manifest from. */
651
+ grantorDID: string;
652
+ };
653
+ export type BeginProvisioningExpectationData = {
654
+ /** Freshly minted attempt id; pass it to `recordProvisioningExpectation`/`abandonProvisioningExpectation`. */
655
+ attemptId: string;
656
+ };
657
+ export type RecordProvisioningExpectationParams = {
658
+ ownerDID: string;
659
+ grantorDID: string;
660
+ /** The id `beginProvisioningExpectation` minted for this attempt. */
661
+ attemptId: string;
662
+ /** The epoch this attempt's grant call was allocated and signed under. */
663
+ epoch: number;
664
+ };
665
+ export type RecordProvisioningExpectationData = {
666
+ status: 'recorded';
667
+ } | {
668
+ status: 'retry';
669
+ /** The recipient's now-held epoch; re-run the grant with this as the new `minEpoch`. */
670
+ minEpoch: number;
671
+ };
672
+ export type AbandonProvisioningExpectationParams = {
673
+ ownerDID: string;
674
+ grantorDID: string;
675
+ /** The id to stop tracking. Removes ONLY this id; never binds or lowers a floor. */
676
+ attemptId: string;
677
+ };
678
+ export type ProvisioningHeldEpochParams = {
679
+ ownerDID: string;
680
+ grantorDID: string;
681
+ };
682
+ export type GrantDeviceCredentialsData = {
683
+ /** One entry per active key `ownerDID` holds on this device, in listed order. */
684
+ results: Array<CredentialGrantOutcome>;
685
+ /**
686
+ * A grantor-signed manifest claiming the structural target — every active
687
+ * key this grantor can itself distribute (`store.listDistributableKeys`) —
688
+ * independent of which of those this particular call's per-key grants
689
+ * actually succeeded on. The recipient checks its store against it to prove
690
+ * it holds the whole set, not just what one batch call happened to land.
691
+ */
692
+ manifest: string;
693
+ /** The provisioning epoch this manifest was allocated and signed under. */
694
+ epoch: number;
695
+ };
573
696
  /** The read access-rule fields a `setCircleSync` add/remove writes for a model. */
574
697
  export type SetReadDefaultParams = {
575
698
  modelID: string;
@@ -718,6 +841,47 @@ export type PeerRequestContext = {
718
841
  * the admin opened or closed since the join.
719
842
  */
720
843
  listCircleSyncStates: (groupID: string) => Promise<Array<CircleSyncStateData>>;
844
+ /**
845
+ * Grant every one of `ownerDID`'s active credential keys to `recipientDID` in
846
+ * one non-throwing batch (each key's failure is its own outcome, never
847
+ * aborting the rest), then return a grantor-signed manifest over what landed.
848
+ */
849
+ grantDeviceCredentials: (params: GrantDeviceCredentialsParams) => Promise<GrantDeviceCredentialsData>;
850
+ /**
851
+ * Prove this device holds the complete expected credential set for
852
+ * `ownerDID` — the union of keys named across every per-grantor manifest.
853
+ * A key counts materialized only when its wrapping decrypt-verifies (or,
854
+ * for a zero-entry key, is merely openable); presence and a valid
855
+ * signature chain alone are not enough.
856
+ */
857
+ credentialProvisioningStatus: (params: CredentialProvisioningStatusParams) => Promise<CredentialProvisioningStatusData>;
858
+ /**
859
+ * Begin tracking a provisioning attempt for `(ownerDID, grantorDID)`: mints
860
+ * an `attemptId` and forces `credentialProvisioningStatus` to `pending`
861
+ * until recorded/abandoned. Call BEFORE asking the grantor to grant, to
862
+ * close the epoch-allocation race window.
863
+ */
864
+ beginProvisioningExpectation: (params: BeginProvisioningExpectationParams) => Promise<BeginProvisioningExpectationData>;
865
+ /**
866
+ * Record the outcome of a provisioning attempt via an atomic
867
+ * compare-and-record against this device's currently-held epoch. If the
868
+ * attempt's epoch still exceeds it, the floor advances (`'recorded'`);
869
+ * otherwise a higher manifest already arrived and the attempt stays
870
+ * outstanding (`'retry'`, `minEpoch`) — retry the grant and call again with
871
+ * the same `attemptId`.
872
+ */
873
+ recordProvisioningExpectation: (params: RecordProvisioningExpectationParams) => Promise<RecordProvisioningExpectationData>;
874
+ /**
875
+ * Stop tracking a dead/abandoned provisioning attempt: removes only this
876
+ * `attemptId` from the outstanding set, never touches the epoch floor.
877
+ */
878
+ abandonProvisioningExpectation: (params: AbandonProvisioningExpectationParams) => Promise<void>;
879
+ /**
880
+ * This device's currently-held epoch for `(ownerDID, grantorDID)` — max
881
+ * epoch among held manifests from that grantor, or `0`. Feeds `minEpoch`
882
+ * for `grantDeviceCredentials` so a new attempt's epoch exceeds it.
883
+ */
884
+ provisioningHeldEpoch: (params: ProvisioningHeldEpochParams) => Promise<number>;
721
885
  };
722
886
  export type P2PJoinRequestContext = {
723
887
  prepareRequest: () => Promise<{
@@ -968,6 +1132,14 @@ export type SyncPluginAPI = {
968
1132
  * no-op (all-zero summary) when no `hub` option is configured.
969
1133
  */
970
1134
  requestLedgerCatchup(groupID: string, options?: LedgerCatchupOptions): Promise<LedgerCatchupSummary>;
1135
+ /**
1136
+ * Build the transport for one directed service-lane session to a
1137
+ * co-member, relayed by the group's hub — the service-lane mirror of the
1138
+ * sync tunnel's route resolver. `undefined` when the group has no hub
1139
+ * bound. A route only: it grants no access to what the far end serves;
1140
+ * that's decided by the far end's own `serviceTunnel` config.
1141
+ */
1142
+ serviceTransportTo(groupID: string, peerDID: string): Promise<ClientTransportOf<ServiceProtocol> | undefined>;
971
1143
  /**
972
1144
  * Declare what this device IS — its display label, its availability class and
973
1145
  * the capabilities it answers — and advertise it to every group it belongs to.