@kubun/plugin-p2p 0.10.0 → 0.11.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 (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,172 @@
1
- import{createEncryptedHubTunnelTransport as t}from"@enkaku/hub-tunnel";import{Server as e}from"@enkaku/server";import{MLSEncryptor as s}from"../groups/mls-encryptor.js";export class HubTunnelSyncListener{#t;#e;#s;#n;#i;#r;#o;#c;#a;#h;#p;#u=!1;#l=!1;#d;#D;#y;constructor(t){this.#t=t.hubConnection,this.#e=t.registry,this.#s=t.groupID,this.#n=t.localDID,this.#i=t.peerDID,this.#r=t.syncHandlers,this.#o=t.getRandomID,this.#c=t.idleTimeoutMs,this.#a=t.reconnectTimeoutMs,this.#h=t.inboxCapacity,this.#p=t.onEvent}start(){this.#u||this.#l||(this.#u=!0,this.#y=new s({registry:this.#e,groupID:this.#s}),this.#D=this.#t.tunnelMessageStream({peerDID:this.#i}),this.#m())}async stop(){if(this.#l)return;this.#l=!0;let t=this.#d;if(this.#d=void 0,null!=t){try{await t.transport.dispose()}catch{}try{await t.server.dispose()}catch{}}let e=this.#D;if(this.#D=void 0,null!=e)try{e.return()}catch{}this.#y=void 0}#m(){let s;if(this.#l)return;let n=this.#y;if(null==n)throw Error("HubTunnelSyncListener: encryptor not initialized; call start() first");let i=t({hub:this.#I(),encryptor:n,groupID:this.#s,sessionID:{auto:!0},localDID:this.#n,peerDID:this.#i,idleTimeoutMs:this.#c,reconnectTimeoutMs:this.#a,inboxCapacity:this.#h,onEvent:this.#p,onSessionEnd:()=>{s?.dispose().catch(()=>{})}});s=i;let r=new e({getRandomID:this.#o,handlers:this.#r,transports:[i]}),o={transport:i,server:r};this.#d=o,i.events.on("disposed",()=>{this.#d===o&&(this.#d=void 0),r.dispose().catch(()=>{}),this.#m()})}#I(){let t=this.#t,e=this.#D;if(null==e)throw Error("HubTunnelSyncListener: stream not initialized; call start() first");return{send:async e=>t.send({recipients:e.recipients,payload:e.payload}),receive:t=>e,events:{subscribe:e=>t.events.on("lifecycle",t=>{let s=function(t){switch(t.type){case"connected":return{type:"connected"};case"disconnected":return{type:"disconnected"};case"reconnecting":return{type:"reconnecting"};case"error":return}}(t);void 0!==s&&e(s)})}}}}
1
+ import { Server } from '@enkaku/server';
2
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
3
+ import { inboxTopic } from '@kumiai/rpc';
4
+ import { createGroupCrypto } from '../groups/group-crypto.js';
5
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
6
+ export class HubTunnelSyncListener {
7
+ #hub;
8
+ #registry;
9
+ #groupID;
10
+ #localDID;
11
+ #peerDID;
12
+ #identity;
13
+ #syncHandlers;
14
+ #getRandomID;
15
+ #idleTimeoutMs;
16
+ #reconnectTimeoutMs;
17
+ #inboxCapacity;
18
+ #onEvent;
19
+ #started = false;
20
+ #stopped = false;
21
+ #current;
22
+ // Single MLSEncryptor reused across spawns. Ordering across overlapping
23
+ // spawns (old transport's last decrypt vs. new spawn's first encrypt) is
24
+ // covered by the GroupHandleRegistry's per-group mutex.
25
+ #encryptor;
26
+ // This device's inbox topic for the live epoch, cached from the first spawn.
27
+ // Within an epoch it is stable (same secret + localDID), so it lets the
28
+ // disposed handler re-subscribe synchronously and close the gap left by the
29
+ // departing transport's teardown-unsubscribe before the async respawn lands.
30
+ #receiveTopicID;
31
+ constructor(params){
32
+ this.#hub = params.hub;
33
+ this.#registry = params.registry;
34
+ this.#groupID = params.groupID;
35
+ this.#localDID = params.localDID;
36
+ this.#peerDID = params.peerDID;
37
+ this.#identity = params.identity;
38
+ this.#syncHandlers = params.syncHandlers;
39
+ this.#getRandomID = params.getRandomID;
40
+ this.#idleTimeoutMs = params.idleTimeoutMs;
41
+ this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
42
+ this.#inboxCapacity = params.inboxCapacity;
43
+ this.#onEvent = params.onEvent;
44
+ }
45
+ start() {
46
+ if (this.#started || this.#stopped) return;
47
+ this.#started = true;
48
+ this.#encryptor = new MLSEncryptor({
49
+ registry: this.#registry,
50
+ groupID: this.#groupID
51
+ });
52
+ void this.#spawn();
53
+ }
54
+ async stop() {
55
+ if (this.#stopped) return;
56
+ this.#stopped = true;
57
+ const current = this.#current;
58
+ this.#current = undefined;
59
+ if (current != null) {
60
+ try {
61
+ await current.transport.dispose();
62
+ } catch {
63
+ // ignore — best-effort teardown
64
+ }
65
+ try {
66
+ await current.server.dispose();
67
+ } catch {
68
+ // ignore — best-effort teardown
69
+ }
70
+ }
71
+ this.#encryptor = undefined;
72
+ }
73
+ async #spawn() {
74
+ if (this.#stopped) return;
75
+ const encryptor = this.#encryptor;
76
+ if (encryptor == null) {
77
+ throw new Error('HubTunnelSyncListener: encryptor not initialized; call start() first');
78
+ }
79
+ // Derive the epoch-bound inbox topics for this spawn. The server accepts
80
+ // inbound frames on its own inbox (`receiveTopicID`) and replies to the
81
+ // peer's inbox (`sendTopicID`). Topics rotate with the MLS epoch, so each
82
+ // spawn re-derives them against the live handle. MLS decrypt inside the
83
+ // registry lock remains the authoritative membership/forward-secrecy gate;
84
+ // stale-after-remove ciphertext surfaces as DecryptError.
85
+ const crypto = createGroupCrypto(this.#registry, this.#groupID);
86
+ const secret = await crypto.exportSecret();
87
+ const epoch = crypto.epoch();
88
+ const sendTopicID = inboxTopic(secret, epoch, this.#peerDID);
89
+ const receiveTopicID = inboxTopic(secret, epoch, this.#localDID);
90
+ this.#receiveTopicID = receiveTopicID;
91
+ // A stop() that landed while we awaited the handle must abort the spawn.
92
+ if (this.#stopped) return;
93
+ let transportRef;
94
+ const transport = createEncryptedHubTunnelTransport({
95
+ hub: this.#hub,
96
+ encryptor,
97
+ groupID: this.#groupID,
98
+ sessionID: {
99
+ auto: true
100
+ },
101
+ localDID: this.#localDID,
102
+ sendTopicID,
103
+ receiveTopicID,
104
+ idleTimeoutMs: this.#idleTimeoutMs,
105
+ reconnectTimeoutMs: this.#reconnectTimeoutMs,
106
+ inboxCapacity: this.#inboxCapacity,
107
+ onEvent: this.#onEvent,
108
+ onSessionEnd: ()=>{
109
+ // Peer signaled end-of-session. Dispose the transport deterministically
110
+ // so the `disposed` event fires and the spawn loop re-arms for the
111
+ // next session arriving on the shared device drain.
112
+ void transportRef?.dispose().catch(()=>{
113
+ // ignore — best-effort
114
+ });
115
+ }
116
+ });
117
+ transportRef = transport;
118
+ // Setting identity forces requireAuth: the tunnel server verifies token
119
+ // signatures and that aud equals its own DID, matching the direct/HTTP sync
120
+ // servers. Per-scope authorization is handled uniformly by the merkle-sync
121
+ // scope-gate, not here.
122
+ const server = new Server({
123
+ getRandomID: this.#getRandomID,
124
+ handlers: this.#syncHandlers,
125
+ identity: this.#identity,
126
+ accessRules: {
127
+ 'sync/*': {
128
+ allow: true
129
+ }
130
+ },
131
+ transports: [
132
+ transport
133
+ ]
134
+ });
135
+ const session = {
136
+ transport,
137
+ server
138
+ };
139
+ this.#current = session;
140
+ // `disposed` may fire more than once for a single session (disposing the
141
+ // server can re-enter the transport's dispose). Collapse to exactly one
142
+ // teardown + respawn so the inbox is re-armed once, not N times.
143
+ let handled = false;
144
+ transport.events.on('disposed', ()=>{
145
+ if (handled) return;
146
+ handled = true;
147
+ if (this.#current === session) {
148
+ this.#current = undefined;
149
+ }
150
+ // Server stays around until its handlers drain; explicit cleanup here so
151
+ // long-running sessions don't leak server instances.
152
+ void server.dispose().catch(()=>{
153
+ // ignore
154
+ });
155
+ // The departing transport's teardown unsubscribes this device's inbox
156
+ // topic. Re-subscribe it eagerly (idempotent server-side) so a peer that
157
+ // reconnects immediately — before the async respawn re-derives and
158
+ // re-subscribes — does not lose its first frame to a momentarily empty
159
+ // topic. Within an epoch the topic is stable, so this targets the same
160
+ // inbox the next spawn will use.
161
+ const receiveTopicID = this.#receiveTopicID;
162
+ if (receiveTopicID != null) {
163
+ void Promise.resolve(this.#hub.subscribe(this.#localDID, receiveTopicID)).catch(()=>{
164
+ // best-effort; the respawn re-subscribes authoritatively
165
+ });
166
+ }
167
+ // Respawn for the next session. The next spawn re-derives the topics
168
+ // against the (possibly advanced) epoch and re-subscribes.
169
+ void this.#spawn();
170
+ });
171
+ }
172
+ }
@@ -1,11 +1,15 @@
1
- import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
2
1
  import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import { type HubLike, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
3
3
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
4
- import type { HubConnection } from '../hub/hub-connection.js';
5
4
  import type { SyncProtocol } from '../protocol.js';
6
5
  import type { SyncTransportProvider } from './sync-client.js';
7
6
  export type HubTunnelSyncProviderParams = {
8
- hubConnection: HubConnection;
7
+ /**
8
+ * Shared multi-subscriber device hub (from `createHubLike`). The transport's
9
+ * `receive()` attaches its own sink to this device-wide drain and filters to
10
+ * `receiveTopicID`; lifecycle events ride `hub.events`.
11
+ */
12
+ hub: HubLike;
9
13
  registry: GroupHandleRegistry;
10
14
  groupID: string;
11
15
  localDID: string;
@@ -21,16 +25,12 @@ export declare class HubTunnelSyncProvider implements SyncTransportProvider {
21
25
  /**
22
26
  * Build a fresh client transport for one tunnel sync session.
23
27
  *
24
- * **Concurrent-call constraint:** the underlying `HubConnection` keys its
25
- * tunnel inbox by `peerDID`, and only one inbox per peer can be registered
26
- * at a time. Callers must therefore fully dispose the previous transport
27
- * (via `client.dispose()`) before invoking `createSyncTransport` again for
28
- * the same provider instance otherwise the second call throws
29
- * `Error('tunnelMessageStream: peerDID already registered')`.
30
- *
31
- * The default `SyncManager.merkleSyncWithPeer` flow already awaits
32
- * `client.dispose()` in its `finally` block, so sequential sync calls are
33
- * safe. Concurrent calls are not.
28
+ * The tunnel rides group-scoped inbox topics derived from the group's current
29
+ * MLS epoch secret: outbound frames publish to the peer's inbox
30
+ * (`inboxTopic(secret, epoch, peerDID)`) and the transport subscribes to this
31
+ * device's own inbox (`inboxTopic(secret, epoch, localDID)`). Topics rotate
32
+ * with the epoch, so a transport built in epoch N stops matching once a commit
33
+ * advances the group — callers create a new transport per session.
34
34
  */
35
- createSyncTransport(signal?: AbortSignal): ClientTransportOf<SyncProtocol>;
35
+ createSyncTransport(signal?: AbortSignal): Promise<ClientTransportOf<SyncProtocol>>;
36
36
  }
@@ -1 +1,64 @@
1
- import{createEncryptedHubTunnelTransport as e}from"@enkaku/hub-tunnel";import{MLSEncryptor as t}from"../groups/mls-encryptor.js";export class HubTunnelSyncProvider{#e;#t;#n;#r;#o;#i;#s;#c;#u;constructor(e){this.#e=e.hubConnection,this.#t=e.registry,this.#n=e.groupID,this.#r=e.localDID,this.#o=e.peerDID,this.#i=e.idleTimeoutMs,this.#s=e.reconnectTimeoutMs,this.#c=e.inboxCapacity,this.#u=e.onEvent}createSyncTransport(n){let r=crypto.randomUUID(),o=new t({registry:this.#t,groupID:this.#n});return e({hub:this.#a(),encryptor:o,groupID:this.#n,sessionID:r,localDID:this.#r,peerDID:this.#o,signal:n,idleTimeoutMs:this.#i,reconnectTimeoutMs:this.#s,inboxCapacity:this.#c,onEvent:this.#u})}#a(){let e=this.#e,t=this.#o;return{send:async t=>e.send({recipients:t.recipients,payload:t.payload}),receive(n){let r=e.tunnelMessageStream({peerDID:t}),o=r[Symbol.asyncIterator](),i={next:()=>o.next(),return:()=>(r.return(),Promise.resolve({value:void 0,done:!0}))};return{[Symbol.asyncIterator]:()=>i,return:()=>{r.return()}}},events:{subscribe:t=>e.events.on("lifecycle",e=>{let n=function(e){switch(e.type){case"connected":return{type:"connected"};case"disconnected":return{type:"disconnected"};case"reconnecting":return{type:"reconnecting"};case"error":return}}(e);void 0!==n&&t(n)})}}}}
1
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
+ import { inboxTopic } from '@kumiai/rpc';
3
+ import { createGroupCrypto } from '../groups/group-crypto.js';
4
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ export class HubTunnelSyncProvider {
6
+ #hub;
7
+ #registry;
8
+ #groupID;
9
+ #localDID;
10
+ #peerDID;
11
+ #idleTimeoutMs;
12
+ #reconnectTimeoutMs;
13
+ #inboxCapacity;
14
+ #onEvent;
15
+ constructor(params){
16
+ this.#hub = params.hub;
17
+ this.#registry = params.registry;
18
+ this.#groupID = params.groupID;
19
+ this.#localDID = params.localDID;
20
+ this.#peerDID = params.peerDID;
21
+ this.#idleTimeoutMs = params.idleTimeoutMs;
22
+ this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
23
+ this.#inboxCapacity = params.inboxCapacity;
24
+ this.#onEvent = params.onEvent;
25
+ }
26
+ /**
27
+ * Build a fresh client transport for one tunnel sync session.
28
+ *
29
+ * The tunnel rides group-scoped inbox topics derived from the group's current
30
+ * MLS epoch secret: outbound frames publish to the peer's inbox
31
+ * (`inboxTopic(secret, epoch, peerDID)`) and the transport subscribes to this
32
+ * device's own inbox (`inboxTopic(secret, epoch, localDID)`). Topics rotate
33
+ * with the epoch, so a transport built in epoch N stops matching once a commit
34
+ * advances the group — callers create a new transport per session.
35
+ */ async createSyncTransport(signal) {
36
+ const sessionID = generateSessionID();
37
+ const encryptor = new MLSEncryptor({
38
+ registry: this.#registry,
39
+ groupID: this.#groupID
40
+ });
41
+ const crypto1 = createGroupCrypto(this.#registry, this.#groupID);
42
+ const secret = await crypto1.exportSecret();
43
+ const epoch = crypto1.epoch();
44
+ const sendTopicID = inboxTopic(secret, epoch, this.#peerDID);
45
+ const receiveTopicID = inboxTopic(secret, epoch, this.#localDID);
46
+ return createEncryptedHubTunnelTransport({
47
+ hub: this.#hub,
48
+ encryptor,
49
+ groupID: this.#groupID,
50
+ sessionID,
51
+ localDID: this.#localDID,
52
+ sendTopicID,
53
+ receiveTopicID,
54
+ signal,
55
+ idleTimeoutMs: this.#idleTimeoutMs,
56
+ reconnectTimeoutMs: this.#reconnectTimeoutMs,
57
+ inboxCapacity: this.#inboxCapacity,
58
+ onEvent: this.#onEvent
59
+ });
60
+ }
61
+ }
62
+ function generateSessionID() {
63
+ return crypto.randomUUID();
64
+ }
@@ -27,6 +27,11 @@ export type ApplySyncMutationsResult = {
27
27
  skipped: number;
28
28
  /** Count of mutations denied by the receive-time access gate. */
29
29
  dropped: number;
30
+ /**
31
+ * Count of mutations whose apply failed transiently (store/db error). No log
32
+ * row is written for these, so the next merkle round re-fetches and retries.
33
+ */
34
+ deferred: number;
30
35
  };
31
36
  /**
32
37
  * Apply mutation JWTs received from a peer via Merkle sync.
@@ -1 +1,194 @@
1
- import{asType as t,createValidator as e}from"@enkaku/schema";import{verifyToken as a}from"@enkaku/token";import{computeMutationHash as i}from"@kubun/engine";import{DocumentID as o}from"@kubun/id";import{documentMutation as n}from"@kubun/protocol";import{getGraphStore as r}from"@kubun/store-graph";import{getP2PStore as u}from"@kubun/store-p2p";import{createReceiveAccessGate as s}from"./receive-access-gate.js";let l=e(n);export async function applySyncMutations(e){let n,{db:c,graph:d,mutationJWTs:p}=e,f=await r(c);if("drop"===(e.storeUnreadable??"persist")){if(null==e.selfDID)throw Error("applySyncMutations: 'storeUnreadable: drop' requires selfDID");let t=await u(c);n=s({selfDID:e.selfDID,db:{getUserModelAccessDefault:(t,e,a)=>f.getUserModelAccessDefault(t,e,a),isMemberOfAnyCircle:(e,a)=>t.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>t.isMemberOfAnyGroup(e,a),getModelInterfaces:t=>f.getModelInterfaces(t)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}})}let m=0,h=0,y=0,w=0,g=0;for(let e of p){let r,u=i(e);if(await f.hasMutationHash(u)){w++;continue}try{let i=await a(e);r=t(l,i.payload)}catch{h++;continue}let s=r.sub,c=o.fromString(s).model.toString(),p=o.fromString(s);if(null==await f.getDocument(p)&&"change"===r.typ){await f.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"pending"}),y++;continue}try{if((await d.applyVerifiedMutation({token:e,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){g++;continue}if(m++,"set"===r.typ)for(let t of(await f.getPendingMutations(s)))try{if((await d.applyVerifiedMutation({token:t.mutation_jwt,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){await f.updateMutationStatus(t.mutation_hash,"rejected"),g++;continue}await f.updateMutationStatus(t.mutation_hash,"applied"),m++}catch{await f.updateMutationStatus(t.mutation_hash,"rejected"),h++}}catch{await f.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"rejected"}),h++}}return{applied:m,rejected:h,pending:y,skipped:w,dropped:g}}
1
+ import { verifyToken } from '@kokuin/token';
2
+ import { computeMutationHash } from '@kubun/engine';
3
+ import { DocumentID } from '@kubun/id';
4
+ import { isPermanentApplyError } from '@kubun/mutation';
5
+ import { documentMutation } from '@kubun/protocol';
6
+ import { getGraphStore } from '@kubun/store-graph';
7
+ import { asType, createValidator } from '@sozai/schema';
8
+ import { rethrowIfProgrammerError } from './forwarder.js';
9
+ import { createReceiveAccessGate } from './receive-access-gate.js';
10
+ const validateMutation = createValidator(documentMutation);
11
+ /**
12
+ * Apply mutation JWTs received from a peer via Merkle sync.
13
+ *
14
+ * Routes the successful-apply path through {@link GraphInternals.applyVerifiedMutation}
15
+ * with `origin: 'peer'` so GraphQL subscriptions (and other engine event
16
+ * consumers) fire for peer-received documents. The engine handles JWT
17
+ * verification, validator cache, applying the mutation, and inserting the
18
+ * `status: 'applied'` mutation log entry.
19
+ *
20
+ * Paths that bypass the engine (manual mutation-log insert):
21
+ * - **skipped**: mutation hash already seen — no-op.
22
+ * - **pending**: change mutation for a document that doesn't exist yet;
23
+ * parked in the log with `status: 'pending'` to be resolved when the
24
+ * set arrives.
25
+ * - **rejected**: JWT verify/validate fails, or engine throws during apply;
26
+ * recorded with `status: 'rejected'`.
27
+ *
28
+ * We still verify + validate the JWT up front to peek at `mutation.typ` for
29
+ * the pending-path routing decision. The engine re-verifies on the success
30
+ * path (defense in depth + avoids plumbing verified payloads through the
31
+ * public API).
32
+ */ export async function applySyncMutations(params) {
33
+ const { db, graph, mutationJWTs } = params;
34
+ const graphStore = await getGraphStore(db);
35
+ const storeUnreadable = params.storeUnreadable ?? 'persist';
36
+ // Build the receive-time access gate as a factory over the apply's
37
+ // transaction-scoped stores so its access reads run inside the apply
38
+ // transaction (a main-connection read there deadlocks on single-connection
39
+ // SQLite). When `'persist'` (default), the gate is omitted —
40
+ // `applyVerifiedMutation` takes its existing zero-overhead no-gate path.
41
+ let accessGate;
42
+ if (storeUnreadable === 'drop') {
43
+ if (params.selfDID == null) {
44
+ throw new Error("applySyncMutations: 'storeUnreadable: drop' requires selfDID");
45
+ }
46
+ const selfDID = params.selfDID;
47
+ const defaultAccessLevel = params.defaultAccessLevel ?? {
48
+ read: 'only_owner',
49
+ write: 'only_owner'
50
+ };
51
+ accessGate = ({ graphStore: txGraphStore, p2pStore: txP2PStore })=>{
52
+ if (txP2PStore == null) {
53
+ return undefined;
54
+ }
55
+ return createReceiveAccessGate({
56
+ selfDID,
57
+ db: {
58
+ getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>txGraphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
59
+ isMemberOfAnyCircle: (viewerDID, circleIDs)=>txP2PStore.isMemberOfAnyCircle(viewerDID, circleIDs),
60
+ isMemberOfAnyGroup: (viewerDID, groupIDs)=>txP2PStore.isMemberOfAnyGroup(viewerDID, groupIDs),
61
+ getModelInterfaces: (modelID)=>txGraphStore.getModelInterfaces(modelID)
62
+ },
63
+ defaultAccessLevel
64
+ });
65
+ };
66
+ }
67
+ let applied = 0;
68
+ let rejected = 0;
69
+ let pending = 0;
70
+ let skipped = 0;
71
+ let dropped = 0;
72
+ let deferred = 0;
73
+ for (const jwt of mutationJWTs){
74
+ const mutationHash = computeMutationHash(jwt);
75
+ // Step 1: Dedup
76
+ if (await graphStore.hasMutationHash(mutationHash)) {
77
+ skipped++;
78
+ continue;
79
+ }
80
+ // Step 2: Verify JWT signature — needed to peek at mutation.typ for
81
+ // pending-path routing.
82
+ let mutation;
83
+ try {
84
+ const verified = await verifyToken(jwt);
85
+ mutation = asType(validateMutation, verified.payload);
86
+ } catch {
87
+ rejected++;
88
+ continue;
89
+ }
90
+ const documentID = mutation.sub;
91
+ const modelID = DocumentID.fromString(documentID).model.toString();
92
+ // Step 3: Check if document exists for change mutations
93
+ const docID = DocumentID.fromString(documentID);
94
+ const doc = await graphStore.getDocument(docID);
95
+ if (doc == null && mutation.typ === 'change') {
96
+ await graphStore.insertMutationLogEntry({
97
+ mutation_hash: mutationHash,
98
+ model_id: modelID,
99
+ document_id: documentID,
100
+ author_did: mutation.iss,
101
+ hlc: mutation.hlc,
102
+ mutation_jwt: jwt,
103
+ status: 'pending'
104
+ });
105
+ pending++;
106
+ continue;
107
+ }
108
+ // Step 4: Route through engine so GraphQL subscriptions fire
109
+ try {
110
+ const applyResult = await graph.applyVerifiedMutation({
111
+ token: jwt,
112
+ origin: 'peer',
113
+ ...accessGate != null ? {
114
+ accessGate
115
+ } : {}
116
+ });
117
+ if (applyResult.dropped) {
118
+ dropped++;
119
+ continue;
120
+ }
121
+ applied++;
122
+ // Step 5: Resolve pending mutations after set
123
+ if (mutation.typ === 'set') {
124
+ const pendingMutations = await graphStore.getPendingMutations(documentID);
125
+ for (const pendingEntry of pendingMutations){
126
+ try {
127
+ const pendingResult = await graph.applyVerifiedMutation({
128
+ token: pendingEntry.mutation_jwt,
129
+ origin: 'peer',
130
+ ...accessGate != null ? {
131
+ accessGate
132
+ } : {}
133
+ });
134
+ if (pendingResult.dropped) {
135
+ // Pending entry denied by gate. Mark the original pending row
136
+ // resolved as `rejected` so downstream consumers don't keep
137
+ // retrying it. The doc itself is on disk via the parent set
138
+ // (which already passed the gate). Counted as `dropped`,
139
+ // not `rejected`, to keep semantics aligned with the gate
140
+ // definition.
141
+ await graphStore.updateMutationStatus(pendingEntry.mutation_hash, 'rejected');
142
+ dropped++;
143
+ continue;
144
+ }
145
+ // Engine inserted a fresh `applied` log entry for the pending
146
+ // mutation. Mark the original `pending` entry resolved too so
147
+ // downstream consumers see consistent state.
148
+ await graphStore.updateMutationStatus(pendingEntry.mutation_hash, 'applied');
149
+ applied++;
150
+ } catch (err) {
151
+ rethrowIfProgrammerError(err);
152
+ // A permanent reject converges the tree (mark resolved). A transient
153
+ // failure leaves the `pending` row untouched: its hash already
154
+ // dedups in the tree, so it is retried not by the next merkle round
155
+ // but when a later set to this document re-walks `getPendingMutations`.
156
+ if (isPermanentApplyError(err)) {
157
+ await graphStore.updateMutationStatus(pendingEntry.mutation_hash, 'rejected');
158
+ rejected++;
159
+ } else {
160
+ deferred++;
161
+ }
162
+ }
163
+ }
164
+ }
165
+ } catch (err) {
166
+ rethrowIfProgrammerError(err);
167
+ // A permanent reject persists a `rejected` row so the tree converges and
168
+ // the sender stops resending. A transient failure writes no row and is
169
+ // counted as deferred, so the next merkle round re-fetches and retries.
170
+ if (isPermanentApplyError(err)) {
171
+ await graphStore.insertMutationLogEntry({
172
+ mutation_hash: mutationHash,
173
+ model_id: modelID,
174
+ document_id: documentID,
175
+ author_did: mutation.iss,
176
+ hlc: mutation.hlc,
177
+ mutation_jwt: jwt,
178
+ status: 'rejected'
179
+ });
180
+ rejected++;
181
+ } else {
182
+ deferred++;
183
+ }
184
+ }
185
+ }
186
+ return {
187
+ applied,
188
+ rejected,
189
+ pending,
190
+ skipped,
191
+ dropped,
192
+ deferred
193
+ };
194
+ }
@@ -1,14 +1,20 @@
1
+ import type { VerifyTokenHook } from '@kokuin/capability';
2
+ import type { SignedPayload } from '@kokuin/token';
1
3
  import type { KubunDB } from '@kubun/db';
2
4
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
5
  import type { Logger } from '@kubun/logger';
4
6
  import type { SyncMerkleSyncParams, SyncMerkleSyncReceive, SyncMerkleSyncResult, SyncMerkleSyncSend } from '../protocol.js';
5
7
  import type { StoreUnreadableMode } from '../types.js';
8
+ import { type PeerRegistry } from './peer-registry.js';
6
9
  /**
7
10
  * Minimal handler context shape for sync/merkle-sync channel.
8
11
  * Matches the Enkaku ChannelHandlerContext at runtime.
9
12
  */
10
13
  type MerkleSyncChannelContext = {
11
14
  param: SyncMerkleSyncParams;
15
+ message: {
16
+ payload: SignedPayload;
17
+ };
12
18
  readable: ReadableStream<SyncMerkleSyncSend>;
13
19
  writable: WritableStream<SyncMerkleSyncReceive>;
14
20
  };
@@ -16,6 +22,13 @@ export type CreateMerkleSyncChannelHandlerParams = {
16
22
  db: KubunDB;
17
23
  graph: GraphInternals;
18
24
  logger: Logger;
25
+ /**
26
+ * Per-peer policy registry. The requested direction is re-resolved against
27
+ * the peer's `allowedDirection` here: merkle-sync is the fail-closed backstop
28
+ * for a client that skips negotiate, so a disallowed direction is denied
29
+ * rather than downgraded.
30
+ */
31
+ peerRegistry: PeerRegistry;
19
32
  /**
20
33
  * Local peer's DID — required when `storeUnreadable === 'drop'` so the
21
34
  * receive-time access gate can use it as viewer.
@@ -28,6 +41,12 @@ export type CreateMerkleSyncChannelHandlerParams = {
28
41
  storeUnreadable?: StoreUnreadableMode;
29
42
  /** Server default access level — required when `storeUnreadable === 'drop'`. */
30
43
  defaultAccessLevel?: DefaultAccessLevel;
44
+ /**
45
+ * Resolves the revocation checker used to authorize delegated scopes. Shared
46
+ * with the negotiate handler so the two access gates use the same revocation
47
+ * state and cannot drift.
48
+ */
49
+ getRevocationChecker?: () => Promise<VerifyTokenHook>;
31
50
  };
32
51
  export declare function createMerkleSyncChannelHandler(params: CreateMerkleSyncChannelHandlerParams): (ctx: MerkleSyncChannelContext) => Promise<SyncMerkleSyncResult>;
33
52
  export {};