@kubun/plugin-p2p 0.10.1 → 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 +3 -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 +105 -91
- 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 +5 -1
- 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 +61 -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 +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- 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 +20 -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 +0 -6
- 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.js +198 -1
- package/lib/hub/wiring.d.ts +17 -48
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +17 -16
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +439 -2
- package/lib/sync/authorize.d.ts +1 -1
- package/lib/sync/authorize.js +73 -1
- 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 +3 -2
- package/lib/sync/errors.js +19 -1
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
- 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 +2 -2
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +1 -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 +3 -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 +11 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.js +21 -1
- package/package.json +47 -40
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.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 -89
- 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 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- 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,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,5 +1,5 @@
|
|
|
1
|
-
import type { VerifyTokenHook } from '@
|
|
2
|
-
import type { SignedPayload } from '@
|
|
1
|
+
import type { VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import type { SignedPayload } from '@kokuin/token';
|
|
3
3
|
import type { KubunDB } from '@kubun/db';
|
|
4
4
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
5
5
|
import type { Logger } from '@kubun/logger';
|
|
@@ -1 +1,182 @@
|
|
|
1
|
-
import{getGraphStore
|
|
1
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
2
|
+
import { authorizeScope } from './authorize.js';
|
|
3
|
+
import { SyncAccessDeniedError } from './errors.js';
|
|
4
|
+
import { applySyncMutations } from './merkle-apply.js';
|
|
5
|
+
import { buildMerkleTree, findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-tree.js';
|
|
6
|
+
import { negotiateDirection } from './peer-registry.js';
|
|
7
|
+
export function createMerkleSyncChannelHandler(params) {
|
|
8
|
+
const { db, graph, logger } = params;
|
|
9
|
+
const storeUnreadable = params.storeUnreadable ?? 'persist';
|
|
10
|
+
return async (ctx)=>{
|
|
11
|
+
const { scopes, excludedDocumentIDs, tree: remoteTree, direction } = ctx.param;
|
|
12
|
+
// Authorize before any store read. The viewer is the cryptographically
|
|
13
|
+
// verified signer (`iss`). A signed payload always carries `iss`; no
|
|
14
|
+
// legitimate kubun client sets `sub`, so a `sub` that differs from `iss`
|
|
15
|
+
// is a forgery attempt and is denied here. Every requested scope must then
|
|
16
|
+
// be authorized (owner or valid delegation); the first unauthorized scope
|
|
17
|
+
// fails the whole request with no partial filtering and no store read.
|
|
18
|
+
// The throw propagates to the caller as a rejected channel call.
|
|
19
|
+
// The payload is always present under requireAuth (identity is set on every
|
|
20
|
+
// sync Server), but guard defensively: a caller that reaches this handler
|
|
21
|
+
// without a verified signer must be denied, never treated as a viewer.
|
|
22
|
+
const payload = ctx.message?.payload;
|
|
23
|
+
if (payload?.iss == null) {
|
|
24
|
+
throw new SyncAccessDeniedError('unsigned', 'Sync access denied: missing verified signer');
|
|
25
|
+
}
|
|
26
|
+
if (payload.sub != null && payload.sub !== payload.iss) {
|
|
27
|
+
throw new SyncAccessDeniedError('forged-subject', 'Sync access denied: signed payload subject does not match issuer');
|
|
28
|
+
}
|
|
29
|
+
const viewerDID = payload.iss;
|
|
30
|
+
const revocationChecker = params.getRevocationChecker != null ? await params.getRevocationChecker() : undefined;
|
|
31
|
+
for (const scope of scopes){
|
|
32
|
+
const authorized = await authorizeScope({
|
|
33
|
+
viewerDID,
|
|
34
|
+
ownerDID: scope.ownerDID,
|
|
35
|
+
delegationTokens: ctx.param.delegationTokens ?? [],
|
|
36
|
+
revocationChecker
|
|
37
|
+
});
|
|
38
|
+
if (!authorized) {
|
|
39
|
+
throw new SyncAccessDeniedError('scope-denied', `Sync access denied: caller not authorized for scope ${scope.modelID}/${scope.ownerDID}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Re-resolve the requested direction against the peer's policy. A client can
|
|
43
|
+
// reach merkle-sync without negotiating, so this is the fail-closed backstop:
|
|
44
|
+
// a direction the peer policy does not allow is denied here, not downgraded.
|
|
45
|
+
// Unknown peers have no policy and default to 'both', so they pass through.
|
|
46
|
+
// Note the policy is keyed on the verified signing DID: it only applies when
|
|
47
|
+
// the signer matches a registered peer DID, so direction restriction is not
|
|
48
|
+
// enforced for callers whose signing identity differs from any peer record
|
|
49
|
+
// (the scope gate above, not this knob, is the access-control boundary).
|
|
50
|
+
const requestedDirection = direction ?? 'pull';
|
|
51
|
+
const peer = await params.peerRegistry.getPeer({
|
|
52
|
+
peerDID: viewerDID,
|
|
53
|
+
stores: db
|
|
54
|
+
});
|
|
55
|
+
const agreedDirection = negotiateDirection(requestedDirection, peer?.allowedDirection ?? 'both');
|
|
56
|
+
if (agreedDirection !== requestedDirection) {
|
|
57
|
+
throw new SyncAccessDeniedError('direction-denied', `Sync access denied: direction ${requestedDirection} not permitted by peer policy`);
|
|
58
|
+
}
|
|
59
|
+
const writer = ctx.writable.getWriter();
|
|
60
|
+
logger.info('sync/merkle-sync channel started', {
|
|
61
|
+
scopes,
|
|
62
|
+
excludedDocumentIDs,
|
|
63
|
+
direction
|
|
64
|
+
});
|
|
65
|
+
let divergentBucketCount = 0;
|
|
66
|
+
let mutationsSent = 0;
|
|
67
|
+
let mutationsReceived = 0;
|
|
68
|
+
try {
|
|
69
|
+
const graphStore = await getGraphStore(db);
|
|
70
|
+
// Step 1: Get all document IDs for the agreed scope
|
|
71
|
+
const documentIDs = await graphStore.getDocumentIDsForScope(scopes, excludedDocumentIDs);
|
|
72
|
+
// Step 2: Get all mutation log entries for these documents
|
|
73
|
+
const localEntries = await graphStore.getMutationLogForDocuments(documentIDs);
|
|
74
|
+
// Step 3: Build local merkle tree
|
|
75
|
+
const localTree = buildMerkleTree(localEntries);
|
|
76
|
+
// Step 4: Send local tree to initiator
|
|
77
|
+
await writer.write({
|
|
78
|
+
type: 'tree',
|
|
79
|
+
tree: localTree.buckets
|
|
80
|
+
});
|
|
81
|
+
// Step 5: Find divergent buckets
|
|
82
|
+
const remoteMerkle = {
|
|
83
|
+
root: remoteTree.root ?? '',
|
|
84
|
+
buckets: remoteTree
|
|
85
|
+
};
|
|
86
|
+
const divergentBuckets = findDivergentBuckets(localTree, remoteMerkle);
|
|
87
|
+
divergentBucketCount = divergentBuckets.length;
|
|
88
|
+
// Step 6: Concurrent send and receive
|
|
89
|
+
const sendTask = async ()=>{
|
|
90
|
+
if (direction === 'pull' || direction === 'both') {
|
|
91
|
+
// Filter local entries to those in divergent buckets
|
|
92
|
+
const divergentSet = new Set(divergentBuckets);
|
|
93
|
+
const entriesToSend = localEntries.filter((entry)=>{
|
|
94
|
+
const { minute } = getTimeBuckets(entry.hlc);
|
|
95
|
+
return divergentSet.has(minute);
|
|
96
|
+
});
|
|
97
|
+
// Send in batches
|
|
98
|
+
for(let i = 0; i < entriesToSend.length; i += SYNC_BATCH_SIZE){
|
|
99
|
+
const batch = entriesToSend.slice(i, i + SYNC_BATCH_SIZE);
|
|
100
|
+
await writer.write({
|
|
101
|
+
type: 'mutations',
|
|
102
|
+
mutationJWTs: batch.map((e)=>e.mutation_jwt)
|
|
103
|
+
});
|
|
104
|
+
mutationsSent += batch.length;
|
|
105
|
+
}
|
|
106
|
+
await writer.write({
|
|
107
|
+
type: 'complete'
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
// direction === 'push': no mutations to send, just signal complete
|
|
111
|
+
await writer.write({
|
|
112
|
+
type: 'complete'
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const receiveTask = async ()=>{
|
|
117
|
+
const collectedJWTs = [];
|
|
118
|
+
const reader = ctx.readable.getReader();
|
|
119
|
+
try {
|
|
120
|
+
while(true){
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done) break;
|
|
123
|
+
if (value.type === 'mutations' && value.mutationJWTs != null) {
|
|
124
|
+
collectedJWTs.push(...value.mutationJWTs);
|
|
125
|
+
} else if (value.type === 'complete') {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} finally{
|
|
130
|
+
reader.releaseLock();
|
|
131
|
+
}
|
|
132
|
+
return collectedJWTs;
|
|
133
|
+
};
|
|
134
|
+
const [, receivedJWTs] = await Promise.all([
|
|
135
|
+
sendTask(),
|
|
136
|
+
receiveTask()
|
|
137
|
+
]);
|
|
138
|
+
// Apply received mutations
|
|
139
|
+
if (receivedJWTs.length > 0) {
|
|
140
|
+
const result = await applySyncMutations({
|
|
141
|
+
db,
|
|
142
|
+
graph,
|
|
143
|
+
mutationJWTs: receivedJWTs,
|
|
144
|
+
selfDID: params.selfDID,
|
|
145
|
+
storeUnreadable,
|
|
146
|
+
defaultAccessLevel: params.defaultAccessLevel
|
|
147
|
+
});
|
|
148
|
+
mutationsReceived = result.applied + result.pending;
|
|
149
|
+
logger.info('sync/merkle-sync: applied received mutations', {
|
|
150
|
+
result
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logger.error('sync/merkle-sync channel error', {
|
|
155
|
+
error
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
divergentBuckets: 0,
|
|
160
|
+
mutationsSent: 0,
|
|
161
|
+
mutationsReceived: 0
|
|
162
|
+
};
|
|
163
|
+
} finally{
|
|
164
|
+
try {
|
|
165
|
+
await writer.close();
|
|
166
|
+
} catch {
|
|
167
|
+
// Writer may already be closed by Enkaku's handler wrapper
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
logger.info('sync/merkle-sync channel completed', {
|
|
171
|
+
divergentBuckets: divergentBucketCount,
|
|
172
|
+
mutationsSent,
|
|
173
|
+
mutationsReceived
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
divergentBuckets: divergentBucketCount,
|
|
178
|
+
mutationsSent,
|
|
179
|
+
mutationsReceived
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
}
|
package/lib/sync/merkle-tree.js
CHANGED
|
@@ -1 +1,120 @@
|
|
|
1
|
-
import{blake3
|
|
1
|
+
import { blake3 } from '@noble/hashes/blake3.js';
|
|
2
|
+
/** Maximum number of mutation JWTs to send in a single channel message. */ export const SYNC_BATCH_SIZE = 1000;
|
|
3
|
+
// Bucket key lengths: year=4, month=7, day=10, minute=16
|
|
4
|
+
const BUCKET_LEVELS = [
|
|
5
|
+
4,
|
|
6
|
+
7,
|
|
7
|
+
10,
|
|
8
|
+
16
|
|
9
|
+
];
|
|
10
|
+
const MINUTE_KEY_LENGTH = 16;
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
export function getTimeBuckets(hlc) {
|
|
13
|
+
// HLC format: "2026-02-14T10:30:45.123Z:0001:nodeID"
|
|
14
|
+
return {
|
|
15
|
+
year: hlc.slice(0, 4),
|
|
16
|
+
month: hlc.slice(0, 7),
|
|
17
|
+
day: hlc.slice(0, 10),
|
|
18
|
+
minute: hlc.slice(0, 16)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function hashHex(input) {
|
|
22
|
+
const digest = blake3(encoder.encode(input));
|
|
23
|
+
return Array.from(digest).map((b)=>b.toString(16).padStart(2, '0')).join('');
|
|
24
|
+
}
|
|
25
|
+
export function buildMerkleTree(entries) {
|
|
26
|
+
if (entries.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
root: '',
|
|
29
|
+
buckets: {}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Group entries by minute bucket
|
|
33
|
+
const minuteGroups = new Map();
|
|
34
|
+
for (const entry of entries){
|
|
35
|
+
const { minute } = getTimeBuckets(entry.hlc);
|
|
36
|
+
let group = minuteGroups.get(minute);
|
|
37
|
+
if (group == null) {
|
|
38
|
+
group = [];
|
|
39
|
+
minuteGroups.set(minute, group);
|
|
40
|
+
}
|
|
41
|
+
group.push(entry);
|
|
42
|
+
}
|
|
43
|
+
const buckets = {};
|
|
44
|
+
// Hash each minute bucket (leaf nodes)
|
|
45
|
+
for (const [minuteKey, bucketEntries] of minuteGroups){
|
|
46
|
+
const sorted = [
|
|
47
|
+
...bucketEntries
|
|
48
|
+
].sort((a, b)=>a.mutation_hash < b.mutation_hash ? -1 : a.mutation_hash > b.mutation_hash ? 1 : 0);
|
|
49
|
+
const content = sorted.map((e)=>e.mutation_hash).join('\n');
|
|
50
|
+
buckets[minuteKey] = hashHex(content);
|
|
51
|
+
}
|
|
52
|
+
// Build parent buckets bottom-up: day from minutes, month from days, year from months
|
|
53
|
+
const parentLevels = [
|
|
54
|
+
10,
|
|
55
|
+
7,
|
|
56
|
+
4
|
|
57
|
+
]// day, month, year (key lengths)
|
|
58
|
+
;
|
|
59
|
+
for (const parentLength of parentLevels){
|
|
60
|
+
const childLength = parentLength === 10 ? 16 : parentLength === 7 ? 10 : 7;
|
|
61
|
+
const groups = new Map();
|
|
62
|
+
for (const key of Object.keys(buckets)){
|
|
63
|
+
if (key.length !== childLength) continue;
|
|
64
|
+
const parentKey = key.slice(0, parentLength);
|
|
65
|
+
let group = groups.get(parentKey);
|
|
66
|
+
if (group == null) {
|
|
67
|
+
group = [];
|
|
68
|
+
groups.set(parentKey, group);
|
|
69
|
+
}
|
|
70
|
+
group.push(key);
|
|
71
|
+
}
|
|
72
|
+
for (const [parentKey, childKeys] of groups){
|
|
73
|
+
const sorted = childKeys.sort();
|
|
74
|
+
const content = sorted.map((k)=>`${k}\0${buckets[k]}`).join('\n');
|
|
75
|
+
buckets[parentKey] = hashHex(content);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Build root hash from year buckets
|
|
79
|
+
const yearKeys = Object.keys(buckets).filter((k)=>k.length === 4).sort();
|
|
80
|
+
const rootContent = yearKeys.map((k)=>`${k}\0${buckets[k]}`).join('\n');
|
|
81
|
+
const root = hashHex(rootContent);
|
|
82
|
+
buckets.root = root;
|
|
83
|
+
return {
|
|
84
|
+
root,
|
|
85
|
+
buckets
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function findDivergentBuckets(local, remote) {
|
|
89
|
+
if (local.root === remote.root) return [];
|
|
90
|
+
// If either tree is empty, all of the other's minute buckets diverge
|
|
91
|
+
if (local.root === '') {
|
|
92
|
+
return Object.keys(remote.buckets).filter((k)=>k.length === MINUTE_KEY_LENGTH).sort();
|
|
93
|
+
}
|
|
94
|
+
if (remote.root === '') {
|
|
95
|
+
return Object.keys(local.buckets).filter((k)=>k.length === MINUTE_KEY_LENGTH).sort();
|
|
96
|
+
}
|
|
97
|
+
const divergent = [];
|
|
98
|
+
const allKeys = new Set([
|
|
99
|
+
...Object.keys(local.buckets),
|
|
100
|
+
...Object.keys(remote.buckets)
|
|
101
|
+
]);
|
|
102
|
+
function walk(prefix, levelIndex) {
|
|
103
|
+
const keyLength = BUCKET_LEVELS[levelIndex];
|
|
104
|
+
const keysAtLevel = [
|
|
105
|
+
...allKeys
|
|
106
|
+
].filter((k)=>k.length === keyLength && (prefix === '' || k.startsWith(prefix))).sort();
|
|
107
|
+
for (const key of keysAtLevel){
|
|
108
|
+
if (local.buckets[key] === remote.buckets[key]) continue;
|
|
109
|
+
if (levelIndex === BUCKET_LEVELS.length - 1) {
|
|
110
|
+
// Leaf level (minute) — this bucket diverges
|
|
111
|
+
divergent.push(key);
|
|
112
|
+
} else {
|
|
113
|
+
// Drill into next level
|
|
114
|
+
walk(key, levelIndex + 1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
walk('', 0);
|
|
119
|
+
return divergent.sort();
|
|
120
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Runtime } from '@enkaku/runtime';
|
|
2
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
|
+
import type { Runtime } from '@sozai/runtime';
|
|
3
3
|
import type { SyncDirection } from '../protocol.js';
|
|
4
4
|
/**
|
|
5
5
|
* Resolve the effective sync direction given what the initiator requested
|