@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -67
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -1 +1,172 @@
|
|
|
1
|
-
import{
|
|
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
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* (
|
|
28
|
-
* the
|
|
29
|
-
*
|
|
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
|
|
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.
|
package/lib/sync/merkle-apply.js
CHANGED
|
@@ -1 +1,194 @@
|
|
|
1
|
-
import{
|
|
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 {};
|