@kubun/plugin-p2p 0.14.0 → 0.15.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 (55) 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/did-cache-seed.d.ts +26 -0
  18. package/lib/groups/did-cache-seed.js +38 -0
  19. package/lib/groups/grantor-authority.d.ts +65 -0
  20. package/lib/groups/grantor-authority.js +107 -0
  21. package/lib/groups/group-handlers.js +7 -0
  22. package/lib/groups/group-peer-manager.d.ts +32 -1
  23. package/lib/groups/group-peer-manager.js +77 -0
  24. package/lib/groups/group-protocols.d.ts +47 -0
  25. package/lib/groups/group-protocols.js +28 -0
  26. package/lib/hub/http-client.js +6 -1
  27. package/lib/hub/hub-like.js +5 -2
  28. package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
  29. package/lib/hub/peer-scoped-hub-view.js +11 -1
  30. package/lib/hub/wiring.d.ts +31 -1
  31. package/lib/hub/wiring.js +20 -1
  32. package/lib/index.d.ts +14 -0
  33. package/lib/index.js +72 -4
  34. package/lib/protocol.d.ts +30 -0
  35. package/lib/protocol.js +36 -0
  36. package/lib/schema.js +78 -1
  37. package/lib/sync/handlers.js +21 -2
  38. package/lib/sync/held-delegations.d.ts +14 -0
  39. package/lib/sync/held-delegations.js +34 -0
  40. package/lib/sync/hub-tunnel-service-listener.d.ts +84 -0
  41. package/lib/sync/hub-tunnel-service-listener.js +294 -0
  42. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  43. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  44. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
  45. package/lib/sync/hub-tunnel-sync-listener.js +6 -1
  46. package/lib/sync/service-tunnel-listeners.d.ts +41 -0
  47. package/lib/sync/service-tunnel-listeners.js +183 -0
  48. package/lib/sync/sync-manager.d.ts +7 -0
  49. package/lib/sync/sync-manager.js +4 -1
  50. package/lib/sync/tunnel-listeners.d.ts +7 -1
  51. package/lib/sync/tunnel-listeners.js +18 -0
  52. package/lib/sync/tunnel-topics.d.ts +19 -1
  53. package/lib/sync/tunnel-topics.js +7 -3
  54. package/lib/types.d.ts +172 -0
  55. package/package.json +49 -48
@@ -0,0 +1,183 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { getP2PStore } from '@kubun/store-p2p';
3
+ import { seedDIDCacheFromRoster } from '../groups/did-cache-seed.js';
4
+ import { HubTunnelServiceListener } from './hub-tunnel-service-listener.js';
5
+ /**
6
+ * The answering half of the service tunnel — one `HubTunnelServiceListener` per
7
+ * co-member per group, so a device dialing on the service lane finds something
8
+ * draining the mailbox and handing the session to the injected `serve()`.
9
+ *
10
+ * Structurally identical to {@link createTunnelListeners} (the sync lane's
11
+ * answering half): same groupID→peerDID map, same epoch/hub-change teardown,
12
+ * same per-group serialized reconciles — only the listener class and what's
13
+ * threaded into it differ, so the two lanes never share an instance or ratchet.
14
+ */ export function createServiceTunnelListeners(params) {
15
+ const selfDID = normalizeDID(params.localDID);
16
+ const { logger } = params;
17
+ // groupID → peerDID → its listener.
18
+ const listeners = new Map();
19
+ // Which drain each group's listeners were built on. A listener holds its hub
20
+ // for life, so a group that switches hubs needs new ones — and the peer set
21
+ // alone cannot show that, since the same members are still wanted.
22
+ const attachedHubs = new Map();
23
+ // And which epoch they derived their topics at. A live listener's subscription
24
+ // is pinned to its spawn epoch; only a disposed transport re-derives, so a
25
+ // group whose epoch moved would otherwise keep draining a stale topic.
26
+ const attachedEpochs = new Map();
27
+ // Reconciles of one group run in sequence: an epoch burst fires several, and
28
+ // two interleaved passes over the same map would each start a listener the
29
+ // other has not recorded yet.
30
+ const queues = new Map();
31
+ let disposed = false;
32
+ const stopGroup = async (groupID)=>{
33
+ attachedHubs.delete(groupID);
34
+ attachedEpochs.delete(groupID);
35
+ const group = listeners.get(groupID);
36
+ if (group == null) {
37
+ return;
38
+ }
39
+ listeners.delete(groupID);
40
+ await Promise.allSettled([
41
+ ...group.values()
42
+ ].map((listener)=>listener.stop()));
43
+ };
44
+ const runReconcile = async (groupID)=>{
45
+ if (disposed) {
46
+ return;
47
+ }
48
+ const store = await getP2PStore(params.stores);
49
+ const profile = await store.getLocalPeerProfile(selfDID);
50
+ if (profile == null) {
51
+ await stopGroup(groupID);
52
+ return;
53
+ }
54
+ const hub = params.tunnelHub(groupID);
55
+ if (hub == null) {
56
+ // No hub bound: there is no mailbox to drain. A binding arriving later
57
+ // reconciles again.
58
+ await stopGroup(groupID);
59
+ return;
60
+ }
61
+ const epoch = params.registry.groupEpoch(groupID);
62
+ if (attachedHubs.get(groupID) !== hub || attachedEpochs.get(groupID) !== epoch) {
63
+ await stopGroup(groupID);
64
+ }
65
+ attachedHubs.set(groupID, hub);
66
+ attachedEpochs.set(groupID, epoch);
67
+ // Bind and membership change both land here, so this is the one seam where a
68
+ // co-member's roster long form is refreshed into the shared cache before any
69
+ // service session that must resolve its short-form issuer can spawn.
70
+ if (params.cache != null) {
71
+ await seedDIDCacheFromRoster({
72
+ cache: params.cache,
73
+ registry: params.registry,
74
+ groupID,
75
+ stores: params.stores,
76
+ ...logger != null ? {
77
+ logger
78
+ } : {}
79
+ });
80
+ }
81
+ const members = await store.listGroupMembers(groupID);
82
+ const wanted = new Set(members.map((member)=>normalizeDID(member.member_did)).filter((did)=>did !== selfDID));
83
+ let group = listeners.get(groupID);
84
+ if (group == null) {
85
+ group = new Map();
86
+ listeners.set(groupID, group);
87
+ }
88
+ for (const [peerDID, listener] of group){
89
+ if (!wanted.has(peerDID)) {
90
+ group.delete(peerDID);
91
+ try {
92
+ await listener.stop();
93
+ } catch (error) {
94
+ logger?.warn('service tunnel listener stop failed', {
95
+ groupID,
96
+ peerDID,
97
+ error
98
+ });
99
+ }
100
+ }
101
+ }
102
+ // Every await above is a point where the group could have been left or the
103
+ // whole thing disposed. A pass that continued past one would repopulate
104
+ // `listeners` with fresh, STARTED listeners for a group this device is no
105
+ // longer in, and they would drain that group's inbox topic indefinitely —
106
+ // nothing later removes what `removeGroup` already walked past.
107
+ if (disposed || listeners.get(groupID) !== group) {
108
+ return;
109
+ }
110
+ for (const peerDID of wanted){
111
+ if (group.has(peerDID)) {
112
+ continue;
113
+ }
114
+ const listener = new HubTunnelServiceListener({
115
+ hub,
116
+ registry: params.registry,
117
+ groupID,
118
+ localDID: selfDID,
119
+ peerDID,
120
+ runtime: params.runtime,
121
+ serve: params.serve,
122
+ services: params.services,
123
+ ...params.cache != null ? {
124
+ cache: params.cache
125
+ } : {},
126
+ ...params.idleTimeoutMs != null ? {
127
+ idleTimeoutMs: params.idleTimeoutMs
128
+ } : {},
129
+ ...logger != null ? {
130
+ logger
131
+ } : {}
132
+ });
133
+ group.set(peerDID, listener);
134
+ listener.start();
135
+ }
136
+ };
137
+ const reconcile = (groupID)=>{
138
+ const queued = (queues.get(groupID) ?? Promise.resolve()).then(()=>runReconcile(groupID), ()=>runReconcile(groupID));
139
+ // Never let one group's failure reject a caller that only asked for a
140
+ // reconcile — the next signal retries, and a throw here would surface on
141
+ // whichever unrelated lane happened to trigger it.
142
+ const settled = queued.catch((error)=>{
143
+ logger?.warn('service tunnel listener reconcile failed', {
144
+ groupID,
145
+ error
146
+ });
147
+ });
148
+ queues.set(groupID, settled);
149
+ return settled;
150
+ };
151
+ return {
152
+ reconcile,
153
+ reconcileAll: async (groupIDs)=>{
154
+ await Promise.all(groupIDs.map((groupID)=>reconcile(groupID)));
155
+ },
156
+ removeGroup: async (groupID)=>{
157
+ // Await the in-flight reconcile BEFORE tearing down. Dropping the queue
158
+ // entry does not stop the pass already running: it would finish after the
159
+ // stop and leave started listeners behind for a group the device has
160
+ // left. `runReconcile` also re-checks, so this is the pair.
161
+ const queued = queues.get(groupID);
162
+ queues.delete(groupID);
163
+ if (queued != null) {
164
+ await queued.catch(()=>{
165
+ // A failed reconcile is already logged; the teardown proceeds.
166
+ });
167
+ }
168
+ await stopGroup(groupID);
169
+ },
170
+ dispose: async ()=>{
171
+ disposed = true;
172
+ const inFlight = [
173
+ ...queues.values()
174
+ ];
175
+ queues.clear();
176
+ await Promise.allSettled(inFlight);
177
+ const groupIDs = [
178
+ ...listeners.keys()
179
+ ];
180
+ await Promise.allSettled(groupIDs.map((groupID)=>stopGroup(groupID)));
181
+ }
182
+ };
183
+ }
@@ -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,5 +1,5 @@
1
1
  import type { ProcedureHandlers } from '@enkaku/server';
2
- import type { OwnIdentity } from '@kokuin/token';
2
+ import type { DIDCache, OwnIdentity } from '@kokuin/token';
3
3
  import type { StoreProvider } from '@kubun/db';
4
4
  import type { Logger } from '@kubun/logger';
5
5
  import type { MailboxHub } from '@kumiai/hub-tunnel';
@@ -13,6 +13,12 @@ export type TunnelListenersParams = {
13
13
  localDID: string;
14
14
  runtime: Runtime;
15
15
  syncHandlers: ProcedureHandlers<SyncProtocol>;
16
+ /**
17
+ * Shared, plugin-lifetime DID cache. Seeded from each group's roster on every
18
+ * reconcile (bind and membership change) and handed to every spawned session's
19
+ * Server, so a co-member's short-form `did:peer:4` issuer resolves there.
20
+ */
21
+ cache?: DIDCache;
16
22
  /** The group's device-wide drain, or `undefined` when no hub is bound. */
17
23
  tunnelHub: (groupID: string) => MailboxHub | undefined;
18
24
  logger?: Logger;
@@ -1,5 +1,6 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { getP2PStore } from '@kubun/store-p2p';
3
+ import { seedDIDCacheFromRoster } from '../groups/did-cache-seed.js';
3
4
  import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
4
5
  /**
5
6
  * The answering half of the tunnel: one listener per co-member per group, so a
@@ -73,6 +74,20 @@ import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
73
74
  }
74
75
  attachedHubs.set(groupID, hub);
75
76
  attachedEpochs.set(groupID, epoch);
77
+ // Bind and membership change both land here, so this is the one seam where a
78
+ // co-member's roster long form is refreshed into the shared cache before any
79
+ // session that must resolve its short-form issuer can spawn.
80
+ if (params.cache != null) {
81
+ await seedDIDCacheFromRoster({
82
+ cache: params.cache,
83
+ registry: params.registry,
84
+ groupID,
85
+ stores: params.stores,
86
+ ...logger != null ? {
87
+ logger
88
+ } : {}
89
+ });
90
+ }
76
91
  const members = await store.listGroupMembers(groupID);
77
92
  const wanted = new Set(members.map((member)=>normalizeDID(member.member_did)).filter((did)=>did !== selfDID));
78
93
  let group = listeners.get(groupID);
@@ -115,6 +130,9 @@ import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
115
130
  identity: params.identity,
116
131
  syncHandlers: params.syncHandlers,
117
132
  runtime: params.runtime,
133
+ ...params.cache != null ? {
134
+ cache: params.cache
135
+ } : {},
118
136
  ...params.idleTimeoutMs != null ? {
119
137
  idleTimeoutMs: params.idleTimeoutMs
120
138
  } : {},
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-p2p",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "license": "see LICENSE.md",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -14,72 +14,73 @@
14
14
  "LICENSE.md"
15
15
  ],
16
16
  "dependencies": {
17
- "@enkaku/client": "^0.21.0",
17
+ "@enkaku/client": "^0.21.2",
18
18
  "@enkaku/http-fetch": "^0.21.0",
19
19
  "@enkaku/http-serve": "^0.21.0",
20
- "@enkaku/protocol": "^0.21.0",
20
+ "@enkaku/protocol": "^0.21.1",
21
21
  "@enkaku/server": "^0.21.2",
22
22
  "@enkaku/transport": "^0.21.0",
23
23
  "@kokuin/capability": "^0.3.0",
24
24
  "@kokuin/controller": "^0.1.0",
25
25
  "@kokuin/token": "^0.5.0",
26
- "@kubun/credential": "^0.14.0",
27
- "@kubun/db": "^0.14.0",
28
- "@kubun/db-adapter": "^0.14.0",
29
- "@kubun/engine": "^0.14.0",
30
- "@kubun/graphql": "^0.14.0",
31
- "@kubun/hlc": "^0.14.0",
32
- "@kubun/http-util": "^0.14.0",
33
- "@kubun/id": "^0.14.0",
34
- "@kubun/logger": "^0.14.0",
35
- "@kubun/mutation": "^0.14.0",
36
- "@kubun/plugin-blob-api": "^0.14.0",
37
- "@kubun/plugin-http": "^0.14.0",
38
- "@kubun/plugin-http-api": "^0.14.0",
39
- "@kubun/plugin-workflow-api": "^0.14.0",
40
- "@kubun/protocol": "^0.14.0",
41
- "@kubun/store-blob": "^0.14.0",
42
- "@kubun/store-controller": "^0.14.0",
43
- "@kubun/store-credential": "^0.14.0",
44
- "@kubun/store-delegation": "^0.14.0",
45
- "@kubun/store-graph": "^0.14.0",
46
- "@kubun/store-p2p": "^0.14.0",
47
- "@kumiai/broadcast": "^0.7.0",
48
- "@kumiai/hub-protocol": "^0.7.0",
49
- "@kumiai/hub-server": "^0.7.0",
50
- "@kumiai/hub-tunnel": "^0.7.0",
51
- "@kumiai/mls": "^0.7.0",
52
- "@kumiai/mls-rpc": "^0.7.0",
53
- "@kumiai/rpc": "^0.7.0",
54
- "@noble/ciphers": "^2.3.0",
55
- "@noble/hashes": "^2.3.0",
26
+ "@kubun/credential": "^0.15.0",
27
+ "@kubun/db": "^0.15.0",
28
+ "@kubun/db-adapter": "^0.15.0",
29
+ "@kubun/engine": "^0.15.0",
30
+ "@kubun/graphql": "^0.15.0",
31
+ "@kubun/hlc": "^0.15.0",
32
+ "@kubun/http-util": "^0.15.0",
33
+ "@kubun/id": "^0.15.0",
34
+ "@kubun/logger": "^0.15.0",
35
+ "@kubun/mutation": "^0.15.0",
36
+ "@kubun/plugin-blob-api": "^0.15.0",
37
+ "@kubun/plugin-http": "^0.15.0",
38
+ "@kubun/plugin-http-api": "^0.15.0",
39
+ "@kubun/plugin-service-api": "^0.15.1",
40
+ "@kubun/plugin-workflow-api": "^0.15.0",
41
+ "@kubun/protocol": "^0.15.0",
42
+ "@kubun/store-blob": "^0.15.0",
43
+ "@kubun/store-controller": "^0.15.0",
44
+ "@kubun/store-credential": "^0.15.0",
45
+ "@kubun/store-delegation": "^0.15.0",
46
+ "@kubun/store-graph": "^0.15.0",
47
+ "@kubun/store-p2p": "^0.15.0",
48
+ "@kumiai/broadcast": "^0.8.0",
49
+ "@kumiai/hub-protocol": "^0.8.0",
50
+ "@kumiai/hub-server": "^0.8.0",
51
+ "@kumiai/hub-tunnel": "^0.8.0",
52
+ "@kumiai/mls": "^0.8.0",
53
+ "@kumiai/mls-rpc": "^0.8.0",
54
+ "@kumiai/rpc": "^0.8.0",
55
+ "@noble/ciphers": "^2.4.0",
56
+ "@noble/hashes": "^2.4.0",
56
57
  "@sozai/async": "^0.2.1",
57
58
  "@sozai/codec": "^0.4.0",
58
59
  "@sozai/event": "^0.1.3",
59
60
  "@sozai/generator": "^0.2.0",
60
61
  "@sozai/runtime": "^0.1.0",
61
- "@sozai/schema": "^0.1.1",
62
+ "@sozai/schema": "^0.1.2",
62
63
  "@sozai/stream": "^0.2.0",
63
64
  "graphql": "^16.14.2",
64
65
  "kysely": "^0.29.5",
65
66
  "ts-mls": "2.0.0-rc.13"
66
67
  },
67
68
  "devDependencies": {
68
- "@kubun/blob-backend": "^0.14.0",
69
- "@kubun/client": "^0.14.0",
70
- "@kubun/db-better-sqlite": "^0.14.0",
71
- "@kubun/db-node-sqlite": "^0.14.0",
72
- "@kubun/db-postgres": "^0.14.0",
73
- "@kubun/hub": "^0.14.0",
74
- "@kubun/plugin-blob": "^0.14.0",
75
- "@kubun/plugin-connector": "^0.14.0",
76
- "@kubun/plugin-service-server": "^0.14.0",
77
- "@kubun/plugin-workflow": "^0.14.0",
78
- "@kubun/service-graph-api": "^0.14.0",
79
- "@kubun/store-workflow": "^0.14.0",
69
+ "@kubun/blob-backend": "^0.15.0",
70
+ "@kubun/client": "^0.15.0",
71
+ "@kubun/db-better-sqlite": "^0.15.0",
72
+ "@kubun/db-node-sqlite": "^0.15.0",
73
+ "@kubun/db-postgres": "^0.15.0",
74
+ "@kubun/hub": "^0.15.0",
75
+ "@kubun/plugin-blob": "^0.15.0",
76
+ "@kubun/plugin-connector": "^0.15.0",
77
+ "@kubun/plugin-service-server": "^0.15.1",
78
+ "@kubun/plugin-workflow": "^0.15.0",
79
+ "@kubun/service-graph-api": "^0.15.0",
80
+ "@kubun/store-workflow": "^0.15.0",
80
81
  "@kubun/test-utils": "^0.13.0",
81
- "@kumiai/hub-conformance": "^0.7.0",
82
- "@kumiai/rpc-conformance": "^0.7.0",
82
+ "@kumiai/hub-conformance": "^0.8.0",
83
+ "@kumiai/rpc-conformance": "^0.8.0",
83
84
  "@testcontainers/postgresql": "^12.1.0",
84
85
  "get-port": "^7.2.0"
85
86
  },